diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index b9f44ebd..00000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "dart": { - "command": "fvm", - "args": [ - "dart", - "mcp-server", - "--force-roots-fallback" - ] - } - } -} diff --git a/.cursor/rules/code-gen-rules.mdc b/.cursor/rules/code-gen-rules.mdc deleted file mode 100644 index f0d002e7..00000000 --- a/.cursor/rules/code-gen-rules.mdc +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: When modifying Dart code that uses build_runner code generation (.g.dart, .freezed.dart). Edit source files only—never edit generated files directly. Run build_runner after changes. -globs: ["**/*.g.dart", "**/*.freezed.dart"] -alwaysApply: false ---- - -Use the appropriate channels and make your changes in the source files that generate the code. If you need to modify anything that appears in a generated file (such as `.g.dart`), update the original source file instead — never edit generated files directly. - -After making changes, run: - -```bash -dart run build_runner build --delete-conflicting-outputs -``` diff --git a/.cursor/skills/frontend-design/SKILL.md b/.cursor/skills/frontend-design/SKILL.md deleted file mode 100644 index 5be498e2..00000000 --- a/.cursor/skills/frontend-design/SKILL.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -name: frontend-design -description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. -license: Complete terms in LICENSE.txt ---- - -This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices. - -The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints. - -## Design Thinking - -Before coding, understand the context and commit to a BOLD aesthetic direction: -- **Purpose**: What problem does this interface solve? Who uses it? -- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. -- **Constraints**: Technical requirements (framework, performance, accessibility). -- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember? - -**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. - -Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: -- Production-grade and functional -- Visually striking and memorable -- Cohesive with a clear aesthetic point-of-view -- Meticulously refined in every detail - -## Frontend Aesthetics Guidelines - -Focus on: -- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. -- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. -- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. -- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density. -- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays. - -NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character. - -Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations. - -**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well. - -Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision. diff --git a/.github/actions/setup-flutter/action.yml b/.github/actions/setup-flutter/action.yml new file mode 100644 index 00000000..2c21485b --- /dev/null +++ b/.github/actions/setup-flutter/action.yml @@ -0,0 +1,47 @@ +name: Set up Flutter +description: >- + Install the Flutter SDK pinned in .fvmrc through FVM, reusing cached pub + packages and SDKs, then fetch the app's dependencies. Expects FVM_HOME and + PUB_CACHE in the job environment. + +runs: + using: composite + steps: + # Its problem matcher resolves against the calling action's folder and + # fails inside a composite action, so it stays off. + - uses: dart-lang/setup-dart@v1 + with: + problem-matcher: false + + - name: Cache Pub Packages + uses: actions/cache@v4 + with: + path: .pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub- + + - name: Cache FVM SDK + uses: actions/cache@v4 + with: + path: .fvm_cache + key: ${{ runner.os }}-fvm-${{ hashFiles('.fvmrc') }} + restore-keys: | + ${{ runner.os }}-fvm- + + # pwsh runs on every runner OS, and calls fvm.bat on Windows correctly. + - name: Add Pub Cache To PATH + shell: pwsh + run: Join-Path $env:PUB_CACHE "bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install FVM + shell: pwsh + run: dart pub global activate fvm + + - name: Install Flutter SDK + shell: pwsh + run: fvm install + + - name: Get Dependencies + shell: pwsh + run: fvm flutter pub get diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc64a39a..25ebcfd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,58 +5,358 @@ on: branches: - main pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR makes its older run pointless, so it is cancelled. +# Each push to main gets a full run of its own. +concurrency: + group: ci-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + RELEASE_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + FVM_HOME: ${{ github.workspace }}/.fvm_cache + PUB_CACHE: ${{ github.workspace }}/.pub-cache jobs: - validate: - runs-on: windows-latest + # Decides which lanes a run needs. Every lane runs for manual runs, PRs into + # main, PRs that change CI itself, and PRs whose files can't be listed. + # Pushes run every lane but the desktop builds: the release runs the upgrade + # and rollback test on its signed installer before publishing. Otherwise a + # PR runs the always-on lanes (analyze, test, web) plus the lanes its + # changed files reach. + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + convex: ${{ steps.lanes.outputs.convex }} + presence: ${{ steps.lanes.outputs.presence }} + contract: ${{ steps.lanes.outputs.contract }} + rust: ${{ steps.lanes.outputs.rust }} + desktop: ${{ steps.lanes.outputs.desktop }} + steps: + - name: Pick lanes from changed files + id: lanes + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + full=false + merged=false + files="" + if [[ "$EVENT" == "push" ]]; then + merged=true + elif [[ "$EVENT" != "pull_request" || "$BASE_REF" == "main" ]]; then + full=true + elif ! files=$(gh pr diff "$PR" --repo "$REPO" --name-only); then + echo "Could not list the PR's files; running every lane." + full=true + elif grep -qE '^\.github/' <<<"$files"; then + full=true + fi + + lane() { + if [[ "$full" == true ]] || + { [[ "$merged" == true ]] && [[ "$1" != desktop ]]; } || + grep -qE "$2" <<<"$files"; then + echo "$1=true" >> "$GITHUB_OUTPUT" + echo "$1: runs" + else + echo "$1=false" >> "$GITHUB_OUTPUT" + echo "$1: skipped, no matching files" + fi + } + # The contract scripts in tool/ run in this lane, against its preview. + lane convex '^(convex/|package(-lock)?\.json$|tsconfig\.json$|vitest\.config|tool/(snapshot|audit)_convex_contract\.mjs$)' + lane presence '^presence/' + lane contract '^(convex/|tool/|lib/collab/)' + lane rust '^third_party/' + # The desktop builds and the upgrade/rollback test, for changes to + # what a shipped build finds in its Hive store, the installer, the + # release scripts, or the test itself. The release runs the same + # test on every signed installer; this catches a break on its PR. + lane desktop '^(installer/|scripts/|lib/migrations/|test/windows_online_beta_library_probe_test\.dart$)|\.g\.dart$' + + analyze: + runs-on: ubuntu-latest + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Check Convex Web Binding Against The Browser Bundle + run: node tool/check_convex_web_binding.mjs + + - uses: ./.github/actions/setup-flutter + + - name: Analyze + run: fvm flutter analyze --no-fatal-infos + + - name: Check Bundled Wall Heights + run: fvm dart run tool/check_bundled_wall_heights.dart + + test: + name: test (shard ${{ matrix.shard }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [0, 1] + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - uses: ./.github/actions/setup-flutter + + - name: Run Tests + run: fvm flutter test --total-shards 2 --shard-index ${{ matrix.shard }} + + # Same flags as "Build Web Client" in deploy-web.yml, which ships the beta + # against the production Convex deployment instead of development. + web: + runs-on: ubuntu-latest + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - uses: ./.github/actions/setup-flutter + + - name: Build Web Client + run: fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development + + convex: + needs: changes + if: needs.changes.outputs.convex == 'true' + runs-on: ubuntu-latest env: - FVM_HOME: ${{ github.workspace }}/.fvm_cache - PUB_CACHE: ${{ github.workspace }}/.pub-cache + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PREVIEW_DEPLOY_KEY }} + # One preview name per PR or branch. The job deletes its preview when it + # finishes, so previews never count against the team deployment quota + # after the run. + CONVEX_PREVIEW_NAME: typed-wrapper-contract-${{ github.event.pull_request.number || github.ref_name }} steps: - - uses: actions/checkout@v4 + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Require Convex preview deploy key + shell: bash + run: | + if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then + echo "Add the CONVEX_PREVIEW_DEPLOY_KEY repository secret so CI can create an isolated contract deployment." + exit 1 + fi + + - name: Install Convex dependencies + run: npm ci + + - name: Check Convex Types + run: npx tsc --noEmit - - uses: dart-lang/setup-dart@v1 + - name: Run Convex Tests + run: npm run test:convex - - name: Cache Pub Packages - uses: actions/cache@v4 + # `--cmd` runs right after Convex claims the preview and before the push, + # so the preview URL is recorded even when typecheck or the push fails. + - name: Deploy isolated Convex contract + run: >- + npx convex deploy --preview-create "$CONVEX_PREVIEW_NAME" --typecheck enable + --cmd-url-env-var-name CONVEX_PREVIEW_URL + --cmd 'echo "CONVEX_PREVIEW_URL=$CONVEX_PREVIEW_URL" >> "$GITHUB_ENV"' + + # Convex keeps previews for days by default and counts them against the + # team's deployment quota. Cap this one at an hour in case the delete + # below never runs (runner lost, job killed). + - name: Expire contract preview within an hour + if: always() && env.CONVEX_PREVIEW_URL != '' + shell: bash + run: | + name="${CONVEX_PREVIEW_URL#https://}"; name="${name%%.*}" + expires_at=$(( ($(date +%s) + 3600) * 1000 )) + curl --fail-with-body -sS -X PATCH \ + -H "Authorization: Bearer $CONVEX_DEPLOY_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"expiresAt\": $expires_at}" \ + "https://api.convex.dev/v1/deployments/$name" + + - name: Compare fresh scrubbed contract + run: | + npm run snapshot:convex-contract:check + npm run audit:convex-contract + git diff --exit-code -- convex/function_spec.json convex/error_codes.json convex/_generated + + # The preview only exists for this run's contract check. A preview deploy + # key may delete preview deployments in its own project. + - name: Delete contract preview + if: always() && env.CONVEX_PREVIEW_URL != '' + shell: bash + run: | + name="${CONVEX_PREVIEW_URL#https://}"; name="${name%%.*}" + curl --fail-with-body -sS -X POST \ + -H "Authorization: Bearer $CONVEX_DEPLOY_KEY" \ + "https://api.convex.dev/v1/deployments/$name/delete" + echo "Deleted Convex preview $name" + + presence-worker: + needs: changes + if: needs.changes.outputs.presence == 'true' + runs-on: ubuntu-latest + defaults: + run: + working-directory: presence + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 with: - path: .pub-cache - key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} - restore-keys: | - ${{ runner.os }}-pub- + ref: ${{ env.RELEASE_SOURCE_SHA }} - - name: Cache FVM SDK - uses: actions/cache@v4 + - uses: actions/setup-node@v4 with: - path: .fvm_cache - key: ${{ runner.os }}-fvm-${{ hashFiles('.fvmrc') }} - restore-keys: | - ${{ runner.os }}-fvm- + node-version: 22 + cache: npm + cache-dependency-path: presence/package-lock.json - - name: Add Pub Cache To PATH - shell: pwsh + - name: Install Presence Worker Dependencies + run: npm ci + + - name: Typecheck Presence Worker + run: npm run typecheck + + # Runs the Worker and its Durable Object in the real Workers runtime. + - name: Test Presence Worker + run: npm test + + # The generated Convex client and the gates around it. + client-contract: + needs: changes + if: needs.changes.outputs.contract == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + # The contract gate records the merge base with origin/main. + # Fetch all branch history so that ref exists on PR and push runs. + fetch-depth: 0 + + - uses: ./.github/actions/setup-flutter + + - name: Validate Convex Client Contract Gate + working-directory: tool/convex_client_gauntlet run: | - "$env:PUB_CACHE\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + fvm dart pub get + fvm dart test + # Analyze this package only. The nested runtime fixtures are separate + # Flutter packages, validated by the workload step below after their + # own dependencies have been resolved. + fvm dart analyze bin lib test - - name: Install FVM - shell: pwsh - run: dart pub global activate fvm + - name: Validate Icarus Convex Generator + working-directory: tool/icarus_convex_codegen + run: | + fvm dart pub get + fvm dart analyze + fvm dart test - - name: Install Flutter SDK - shell: pwsh - run: fvm install + - name: Validate Protocol V3 Gauntlet Workload + working-directory: tool/convex_client_gauntlet/runtime + run: | + fvm flutter pub get + fvm dart analyze lib test tool + fvm flutter test test/workload_test.dart - - name: Get Dependencies - shell: pwsh - run: fvm flutter pub get + - name: Reject Generated Client Drift + run: | + fvm dart run tool/icarus_convex_codegen/bin/generate.dart + fvm dart run tool/icarus_convex_codegen/bin/generate.dart + git diff --exit-code -- lib/collab/generated - - name: Analyze + native-bridge: + needs: changes + if: needs.changes.outputs.rust == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - name: Test Native Convex Bridge + run: | + cargo test --manifest-path third_party/convex_flutter/rust/Cargo.toml + cargo test --manifest-path third_party/convex_rs/Cargo.toml + + linux-build: + needs: changes + if: needs.changes.outputs.desktop == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - name: Install Linux build dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev + + - uses: ./.github/actions/setup-flutter + + # Cargokit builds the Rust bridge into the plugin's build folder. Its + # compiled dependencies are cached; the bridge crate itself rebuilds. + - uses: Swatinem/rust-cache@v2 + with: + workspaces: third_party/convex_flutter/rust -> ../../../build/linux/x64/release/plugins/convex_flutter/cargokit_build + + - name: Build Linux Client + run: fvm flutter build linux --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development + + # Builds the Windows installer the way a release does and upgrades the last + # public release to it, then rolls back. + windows-build: + needs: changes + if: needs.changes.outputs.desktop == 'true' + runs-on: windows-latest + defaults: + run: shell: pwsh - run: fvm flutter analyze --no-fatal-infos + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - name: Test Release Safety Policy + run: powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1 - name: Validate PowerShell Scripts - shell: pwsh run: | $parseErrors = @( Get-ChildItem -Path scripts, installer -Filter "*.ps1" -File | ForEach-Object { @@ -75,12 +375,66 @@ jobs: throw "PowerShell parse errors: $($parseErrors.Message -join ', ')" } - - name: Check Bundled Wall Heights - shell: pwsh - run: fvm dart run tool/check_bundled_wall_heights.dart - name: Test Release Signing Gates shell: powershell run: ./scripts/test_release_signing.ps1 - - name: Run Tests + + - uses: ./.github/actions/setup-flutter + + # Cargokit builds the Rust bridge into the plugin's build folder. Its + # compiled dependencies are cached; the bridge crate itself rebuilds. + - uses: Swatinem/rust-cache@v2 + with: + workspaces: third_party/convex_flutter/rust -> ../../../build/windows/x64/plugins/convex_flutter/cargokit_build + + - name: Build Windows Client + run: fvm flutter build windows --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development + + - name: Build Windows Installer + run: powershell -ExecutionPolicy Bypass -File installer/build_installer.ps1 -Configuration Release + + - name: Test Public Upgrade And Rollback + run: | + $installer = Get-ChildItem -Path build/installer -Filter 'icarus-setup-*.exe' | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($null -eq $installer) { + throw "Release-candidate installer was not built." + } + powershell -ExecutionPolicy Bypass -File scripts/test_online_beta_windows_upgrade.ps1 ` + -ReleaseCandidateInstaller $installer.FullName + + - name: Upload Windows Release Candidate + uses: actions/upload-artifact@v4 + with: + name: icarus-windows-rc-${{ env.RELEASE_SOURCE_SHA }} + path: | + build/installer/icarus-setup-*.exe + ${{ runner.temp }}/icarus-online-beta-windows-smoke/evidence.json + if-no-files-found: error + + # The suite on the platform we ship, for the same changes as windows-build. + windows-test: + needs: changes + if: needs.changes.outputs.desktop == 'true' + runs-on: windows-latest + defaults: + run: shell: pwsh + steps: + - name: Checkout Release Source + uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_SOURCE_SHA }} + + - uses: ./.github/actions/setup-flutter + + - name: Run Tests run: fvm flutter test + + - name: Test Native Convex Bridge + if: needs.changes.outputs.rust == 'true' + run: | + cargo test --manifest-path third_party/convex_flutter/rust/Cargo.toml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --manifest-path third_party/convex_rs/Cargo.toml diff --git a/.github/workflows/deploy-convex-production.yml b/.github/workflows/deploy-convex-production.yml new file mode 100644 index 00000000..cc2afc85 --- /dev/null +++ b/.github/workflows/deploy-convex-production.yml @@ -0,0 +1,68 @@ +name: Deploy Convex Production + +on: + workflow_dispatch: + inputs: + confirmation: + description: Type deploy-production to confirm this production backend deploy. + type: string + required: true + +permissions: + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Production Deploy + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "The production Convex backend can only deploy from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ "$CONFIRMATION" != "deploy-production" ]]; then + echo "Confirmation must be exactly: deploy-production" + exit 1 + fi + + - name: Checkout Production Source + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Require Production Convex Deploy Key + shell: bash + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: | + if [[ -z "$CONVEX_DEPLOY_KEY" ]]; then + echo "Add CONVEX_PRODUCTION_DEPLOY_KEY to the GitHub Production environment." + exit 1 + fi + if [[ "$CONVEX_DEPLOY_KEY" != prod:* ]]; then + echo "CONVEX_PRODUCTION_DEPLOY_KEY must be a production deploy key with the prod: prefix." + exit 1 + fi + + - name: Install Convex Dependencies + run: npm ci + + - name: Check Convex Types + run: npx tsc --noEmit + + - name: Run Convex Tests + run: npm run test:convex + + - name: Deploy Convex Production Backend + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: npx convex deploy --typecheck enable --message "GitHub Actions $GITHUB_SHA" diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 00000000..c9192ec6 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,186 @@ +name: Deploy Web + +# Publishes the web beta: first the Convex server to the production +# deployment and the presence Worker, then the Flutter web build (built +# against that deployment) to the Cloudflare Pages project `icarus-web`, served at +# https://beta.icarusstrats.com. Every push to main that touches the +# server or the web build deploys both, server first, so the site never runs +# ahead of the backend it talks to. Actions > Deploy Web > Run workflow +# redeploys. +# Setup and secrets: docs/release_process.md, "Web beta deploy". + +on: + push: + branches: + - main + paths: + - lib/** + - web/** + - assets/** + - shaders/** + # Path dependencies from pubspec.yaml compile into the web build. + - packages/** + - third_party/convex_flutter/** + - pubspec.yaml + - pubspec.lock + - .fvmrc + - convex/** + - presence/** + - package.json + - package-lock.json + - .github/workflows/deploy-web.yml + workflow_dispatch: + +permissions: + contents: read + +# Deploys queue rather than cancel each other. A run cancelled after the +# Convex deploy but before Pages would leave the live web build on a server +# it may not speak to; the next run then publishes the newest build anyway. +concurrency: + group: deploy-web + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + env: + FVM_HOME: ${{ github.workspace }}/.fvm_cache + PUB_CACHE: ${{ github.workspace }}/.pub-cache + + steps: + - name: Require main, secrets and production settings + shell: bash + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CONVEX_PRODUCTION_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: | + # Every run replaces the live beta, so a manual run from another + # branch would ship unreviewed code to testers. + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::Deploy Web only runs from main. This run is on $GITHUB_REF." + exit 1 + fi + missing=0 + for name in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID CONVEX_PRODUCTION_DEPLOY_KEY ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL ICARUS_PRODUCTION_CONVEX_CLIENT_ID; do + if [[ -z "${!name}" ]]; then + echo "::error::Add $name (repository secret or variable). See docs/release_process.md, \"Web beta deploy\"." + missing=1 + fi + done + if [[ -n "$CONVEX_PRODUCTION_DEPLOY_KEY" && "$CONVEX_PRODUCTION_DEPLOY_KEY" != prod:* ]]; then + echo "::error::CONVEX_PRODUCTION_DEPLOY_KEY must be a production deploy key (prod: prefix)." + missing=1 + fi + exit $missing + + - uses: actions/checkout@v4 + + # The server ships first. If it fails (type errors, schema that doesn't + # fit the data), nothing is published and the live site keeps working + # against the server it already knows. + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install Convex Dependencies + run: npm ci + + - name: Deploy Convex Production Backend + env: + CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_PRODUCTION_DEPLOY_KEY }} + run: npx convex deploy --typecheck enable --message "Deploy Web $GITHUB_SHA" + + # Live presence (who is on a strategy, their cursors) runs in its own + # Worker. It pairs with production Convex through PRESENCE_URL and + # PRESENCE_PASS_SECRET, set once on both sides (presence/README.md). + - name: Install Presence Worker Dependencies + working-directory: presence + run: npm ci + + - name: Deploy Presence Worker + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: presence + command: deploy --env production + + # Flutter setup mirrors the linux job in ci.yml: FVM installs the + # version pinned in .fvmrc. + - uses: dart-lang/setup-dart@v1 + + - name: Cache Pub Packages + uses: actions/cache@v4 + with: + path: .pub-cache + key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }} + restore-keys: | + ${{ runner.os }}-pub- + + - name: Cache FVM SDK + uses: actions/cache@v4 + with: + path: .fvm_cache + key: ${{ runner.os }}-fvm-${{ hashFiles('.fvmrc') }} + restore-keys: | + ${{ runner.os }}-fvm- + + - name: Add Pub Cache To PATH + shell: bash + run: echo "$PUB_CACHE/bin" >> "$GITHUB_PATH" + + - name: Install FVM and Flutter SDK + shell: bash + run: | + dart pub global activate fvm + fvm install + + - name: Get Dependencies + run: fvm flutter pub get + + # Same flags as "Build Web Client" in ci.yml; only the Convex target + # differs. CI builds against development, the beta ships against the + # production deployment the step above just updated. + - name: Build Web Client + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: >- + fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons + --dart-define=ICARUS_CLOUD_ENVIRONMENT=production + --dart-define=ICARUS_CONVEX_DEPLOYMENT_URL="$ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL" + --dart-define=ICARUS_CONVEX_CLIENT_ID="$ICARUS_PRODUCTION_CONVEX_CLIENT_ID" + + # --branch=main: Cloudflare Pages treats a deploy to the project's + # production branch (main) as the production deployment, and the custom + # domain beta.icarusstrats.com follows production. Any other branch name + # would only create a preview URL. This Pages branch name is unrelated + # to the git branch, though both are main. + - name: Deploy to Cloudflare Pages + id: deploy + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # wrangler-action v4 installs Wrangler 4 by default. + command: pages deploy build/web --project-name=icarus-web --branch=main --commit-hash=${{ github.sha }} + + - name: Summarize Deployment + shell: bash + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + PAGES_ENVIRONMENT: ${{ steps.deploy.outputs.pages-environment }} + run: | + { + echo "### Web beta deployed (production Convex)" + echo "" + echo "- Live: https://beta.icarusstrats.com" + echo "- This deployment: ${DEPLOYMENT_URL:-see the Deploy to Cloudflare Pages log}" + echo "- Pages environment: ${PAGES_ENVIRONMENT:-unknown} (production means the custom domain now serves it)" + echo "- Commit: \`$GITHUB_SHA\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index fd09db3c..0873623d 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -47,8 +47,36 @@ permissions: id-token: write jobs: + production-approval: + if: ${{ inputs.channel == 'stable' }} + runs-on: ubuntu-latest + environment: Production + + steps: + - name: Guard Stable Desktop Release + shell: bash + env: + PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: | + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Stable desktop releases can only run from branch main. Current ref: $GITHUB_REF" + exit 1 + fi + if [[ -z "$PRODUCTION_CONVEX_DEPLOYMENT_URL" || -z "$PRODUCTION_CONVEX_CLIENT_ID" ]]; then + echo "Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before releasing stable." + exit 1 + fi + build: + needs: production-approval + if: ${{ always() && (inputs.channel == 'prerelease' || needs.production-approval.result == 'success') }} runs-on: windows-latest + env: + # Every release_desktop.ps1 phase resolves the cloud build configuration, + # so stable builds need the production Convex settings in each step. + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} steps: - uses: actions/checkout@v4 @@ -60,6 +88,12 @@ jobs: shell: pwsh run: throw "Signed desktop releases must be dispatched from main." + - name: Run Release Preflight + shell: pwsh + run: >- + powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 + -ReleaseTarget "${{ inputs.channel == 'stable' && 'stable-desktop' || 'prerelease-desktop' }}" + - name: Sign In To Azure With OIDC uses: azure/login@v3 with: @@ -163,6 +197,22 @@ jobs: Assert-AuthenticodeSignatures -Path "build/windows/x64/runner/Release" Assert-AuthenticodeSignatures -Path "build/installer" + # The gate for players' libraries: install the last public build, import + # a historical .ica, upgrade to this signed installer, then roll back, + # checking the Strategy library survives each step. Nothing is staged or + # published if it fails. + - name: Test Public Upgrade And Rollback + shell: pwsh + run: | + $installer = Get-ChildItem -Path build/installer -Filter 'icarus-setup-*.exe' | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($null -eq $installer) { + throw "The signed installer was not found in build/installer." + } + powershell -ExecutionPolicy Bypass -File scripts/test_online_beta_windows_upgrade.ps1 ` + -ReleaseCandidateInstaller $installer.FullName + - name: Stage Desktop Release shell: pwsh env: diff --git a/.github/workflows/release-store.yml b/.github/workflows/release-store.yml index 3c142c1a..5657f3e5 100644 --- a/.github/workflows/release-store.yml +++ b/.github/workflows/release-store.yml @@ -25,12 +25,20 @@ permissions: jobs: build: runs-on: windows-latest + environment: Production steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Run Store Release Preflight + shell: pwsh + env: + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} + run: powershell -ExecutionPolicy Bypass -File scripts/assert_release_preflight.ps1 -ReleaseTarget store + - uses: dart-lang/setup-dart@v1 - name: Add Pub Cache To PATH @@ -55,6 +63,8 @@ jobs: shell: pwsh env: POSTHOG_PROJECT_TOKEN: ${{ secrets.POSTHOG_PROJECT_TOKEN }} + ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL: ${{ vars.ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL }} + ICARUS_PRODUCTION_CONVEX_CLIENT_ID: ${{ vars.ICARUS_PRODUCTION_CONVEX_CLIENT_ID }} run: powershell -ExecutionPolicy Bypass -File scripts/build_store_release.ps1 - name: Upload Store Artifacts diff --git a/AGENTS.md b/AGENTS.md index f895f68f..174b3670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ -i(Dara,me) want to write this to you(agent). Icarus is mine, and we take care of it together. +i(Dara,me) want to write this to you(agent). Icarus is mine, and we take care of it together. This branch is where Icarus learns to live online. -Icarus is a Valorant strategy and lineup tool: a desktop Flutter app where players, igl's, and coaches draw plans on maps, place agents and abilities, and build a library of their tactical work. It ships as an installer with an auto-updater and has over a thousand users. +Icarus is a Valorant strategy and lineup tool: a desktop Flutter app where players, igl's, and coaches draw plans on maps, place agents and abilities, and build a library of their tactical work. The shipped version keeps everything on the user's machine. This branch adds the cloud: accounts, a Convex backend, sync, and shared strategies. The web beta is public and real users keep their libraries on the production deployment. We are still finding the right shape for these features, but the shape now has people living in it. Quick glossary of relevant parties in this document: @@ -12,10 +12,13 @@ And the words we use when we work: simple - how cleanly the logic breaks down. each step follows from the last, no step doing two jobs. obvious - the next reader never asks "why is this here?". measured by the reader. not always simple; sometimes obvious has more parts. -the library - a user's saved strategies, folders, and lineups. stored only on their machine, no copy exists anywhere else. +the library - a user's saved strategies, folders, and lineups. in local mode it lives only on their machine; in cloud mode a copy lives on the server. both copies are the user's work. round-trip - export then import with nothing lost. +local mode - the shipped behavior: signed out, library on disk, exactly what main's users have today. +cloud mode - signed in, library synced to Convex. +op - one queued change to cloud data. an op lands when the server accepts it. -The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md holds the rules for how we build UI (the values themselves live in `lib/const/settings.dart`), read it before touching UI. +The domain vocabulary (strategy, page, lineup, .ica file, and friends) lives in CONTEXT.md, use those words exactly. DESIGN.md holds the rules for how we build UI (the values themselves live in `lib/const/settings.dart`), read it before touching UI. PRODUCT.md holds who this is for and how it must feel. Here's the philosophy we work by: @@ -24,16 +27,22 @@ After audits, investigations, or substantial testing runs, read [answers.md](ans Before changing view cones, map blockers, or elevation handling, read [docs/vision-model.md](docs/vision-model.md). It defines which data controls wall placement and height. ## The library is sacred -Corrupted or dropped library data is unrecoverable. Schema changes are the dangerous moment: a change to the Hive models means source models, generated adapters, and a migration (`lib/migrations/`) so that data written by any past version loads in this one. When a write path is uncertain, fail loudly without saving rather than save something wrong. +Corrupted or dropped library data is unrecoverable. In local mode nothing here changes: a change to the Hive models means source models, generated adapters, and a migration (`lib/migrations/`) so that data written by any past version loads in this one. In cloud mode the op queue holds work the user believes is saved. Every op either lands or the user is told, on screen, before they walk away. When a write path is uncertain, fail loudly without saving rather than save something wrong. + +## Sync status is a promise +The chip that says synced is the app promising the work is on the server. A conflict resolved in silence makes the app lie. So does an op dropped after retries, and so do offline edits with no badge. A user who catches the app lying once stops trusting it with their library. Every state the user's work can be in has a face on screen, and when the true state is uncertain, show uncertainty. + +## The server has users now +The clay hardened: production Convex holds real users' libraries, and a cloud library is as sacred as a local one. Never wipe a deployment that users write to. A schema change ships as a migration: widen the schema so old and new rows both validate, backfill in pages that reschedule themselves and record when they finish (`backfillAssetReferences` in `convex/maintenance.ts` is the pattern), and only narrow the schema once the backfill is done everywhere. Merging `convex/` changes deploys to production, so every server change must keep working with the web build that is live and every desktop build we shipped; gate new server strictness behind something new clients opt into. The dev deployment still holds only our data and can be reshaped freely. ## Everything exported must come home Every .ica file and library backup from every version we ever shipped must round-trip. When you change what a strategy contains, export and import change with it in the same commit. -## Build the data layer like the server is already reading it -An online version is in the works. We are not building it here, but nothing we write should make it harder. Keep serialization versioned and free of UI entanglement. Think "could this format cross a network and be read by code that has never seen our widgets?" +## The server is reading it now +On main we build the data layer as if a server were already reading it. Here one is. `convex/` reads every byte the client writes and has never seen our widgets, and that stays true: payloads carry a payloadVersion, rows carry a revision, JSON is canonical, and nothing under `lib/collab/` or `convex/` imports UI. ## The user is mid-thought -People use Icarus while their tactical idea is still hot, the interface must never make them wait or wonder. So when a feature works but feels wrong, it is not done. +People use Icarus while their tactical idea is still hot, the interface must never make them wait or wonder. The network is never in that loop. Edits hit the canvas immediately and the queue catches up behind them. When a feature works but feels wrong, it is not done. ## Fight for the obvious solution Measure twice, cut once: understand the problem fully before building, because cleverness is what gets written when you haven't. The biggest simplicity win is refusing to solve problems we don't have. Good code is the most simple thing that delivers full functionality, nothing traded away, nothing bolted on. Push back when you see a more obvious way. @@ -41,5 +50,7 @@ Measure twice, cut once: understand the problem fully before building, because c ## Some general rules These steer us in the right direction. They are not hard-set, but default to following them; if you think one should be ignored, be very loud about it and get approval from us first. -- Never edit `*.g.dart` files. Edit the source models, then run `dart run build_runner build --delete-conflicting-outputs`. -- Each thread owns one running instance of the app, and never touches another's. Start yours once, keyed by your worktree or branch name: `flutter run -d macos --pid-file /tmp/icarus-.pid --dart-entrypoint-args "--hive-store-dir="`, with `` = `~/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/icarus-` so its library is yours alone, launched from an unsandboxed shell or it draws but takes no clicks. Instances share a window frame, so move yours aside before you click. After every edit, hot reload it with `kill -USR1 $(cat /tmp/icarus-.pid)`; the tool's stdin is not a terminal, so typing `r` does nothing. Keep it running for the thread, and kill it when the thread ends. +- Never edit generated files (`*.g.dart`, `convex/_generated/`). Edit the source models or schema, then regenerate (`dart run build_runner build --delete-conflicting-outputs`; Convex regenerates via `npx convex dev`). +- Convex behavior is defined by the current schema, source, generated types, and tests in this repository. If an API detail is uncertain, check the current official Convex documentation before editing, then prove the change with `npx tsc --noEmit` and `npm run test:convex`. +- Each thread owns one running instance of the app, and never touches another's. Start yours once, keyed by your worktree or branch name: `flutter run -d macos --pid-file /tmp/icarus-.pid --dart-entrypoint-args "--hive-store-dir="`, with `` = `~/Library/Containers/xyz.icarus-strats/Data/Library/Application Support/icarus-` so its library is yours alone, launched from an unsandboxed shell or it draws but takes no clicks. Instances share a window frame, so move yours aside before you click. After every edit, hot reload it with `kill -USR1 $(cat /tmp/icarus-.pid)`; the tool's stdin is not a terminal, so typing `r` does nothing. Keep it running for the thread, and kill it when the thread ends. The signed-in session is shared across instances: sign out from yours and you sign everyone out. +- Do not add generated agent guidance or bundled skills back to the repository. diff --git a/CONTEXT.md b/CONTEXT.md index e951f111..d76e2e85 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -41,6 +41,14 @@ each page held for the step duration with full-fidelity page transitions between them. _Avoid_: video sequencing, movie export +**Library tab**: +One of the three destinations in the library's title strip. *My Library* is +the user's own strategies and folders from every store, shown together; +*Shared* is what teammates shared with them; *Community* is the public space. +Whether a strategy is on this device, in the cloud, or both is a badge on the +strategy, not a place the user goes. +_Avoid_: This Computer, workspace (in user-facing copy) + **Lineup**: A saved ability setup (position/aim reference) attached to a page, grouped into lineup groups. @@ -49,3 +57,13 @@ into lineup groups. Icarus's zip-based strategy interchange format for import/export of whole strategies. Unrelated to video export. _Avoid_: archive (ambiguous with library backups) +**Op**: +One queued change to cloud data. An op lands when the server accepts it. +Its op ID names that exact change. Changing the intended work creates a new op +with a new ID; retrying the same work keeps the existing ID. +_Avoid_: request, event + +**Outbox record**: +The durable saved form of one queued op and its delivery state, used to recover +unsent cloud work after an app restart. It is not a server payload. +_Avoid_: payload, cached request diff --git a/DESIGN.md b/DESIGN.md index e6325343..0b76b174 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -17,9 +17,9 @@ The palette, theme, and sizing constants live in `lib/const/settings.dart`, with - Desktop builds hide the native title bar. Each top-level screen draws its own 40px strip (`lib/widgets/window_chrome.dart`): macOS keeps its traffic lights, so the strip leaves a 78px inset on the left; Windows and Linux get app-drawn caption buttons on the right; the strip is the drag handle. Web renders the same strip with no inset and no buttons. - An update icon exists only while an update is waiting, drawn by `AppWindowStrip` itself just before the caption buttons (`lib/widgets/strip_status_icons.dart`), so it shows on every screen and no screen places it. -- The library strip holds the three tabs on the left and only What's new (past patch notes), search, sort, and New on the right (there is no account yet). Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. +- The library strip holds the three tabs on the left and only What's new (past patch notes), search, sort, New, and the account on the right. Nothing else goes in it. Inside a folder, the breadcrumb lives in the content area, not the strip. - The app version lives at the foot of the settings navigation rail, muted; click copies it. It is not in any strip. -- The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). No status chips or labels in the editor. +- The editor's document actions (save, export, video, screenshot, settings) sit in one card at the top-left of the canvas (`lib/widgets/editor_toolbar.dart`). For a cloud strategy the save button's glyph is the sync state; the words live in its tooltip and a popover opens only when sync needs attention. No status chips or labels in the editor. ## Icons diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 00000000..e4d4e01f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,41 @@ +# Product + +## Register + +product + +## Users + +Icarus is for Valorant players, in-game leaders, coaches, and small teams who need a focused desktop workspace for planning rounds. They are usually reviewing maps, building strategies, marking utility, saving reusable setups, or preparing material to share with teammates. + +The user context is practical and time-sensitive: they need to place agents, abilities, drawings, notes, images, and lineups quickly without the interface getting in the way of tactical thinking. + +## Product Purpose + +Icarus is an interactive Valorant strategy and lineup tool. It exists to make strategy creation feel fast, local, organized, and easy to revisit. Success means a user can move from an idea to a usable tactical map with minimal friction, then save, organize, import, export, and refine that work over time. + +The app should make complex strategy work feel manageable. The map and placed tactical objects are the main artifact; surrounding UI should support scanning, editing, and control without competing with the canvas. + +## Brand Personality + +Minimal, intuitive, tasteful, and fast. + +The product should feel refined and deliberate. Controls should look like they belong where they are placed, workflows should feel obvious after a short glance, and dense tools should remain calm enough for repeated use during planning sessions. + +## Anti-references + +Icarus should not look disorganized, noisy, or arbitrary. Nothing should feel placed only because there was space for it. Avoid flashy color, novelty effects, cluttered panels, inconsistent spacing, and features that look shoehorned into the interface. + +The app should not feel like a generic gamer overlay, a marketing page, or a decorative dashboard. It should not rely on loud gradients, excessive glow, glass effects, or visual noise to create excitement. + +## Design Principles + +1. Canvas first: the strategy map is the work surface, and every surrounding control should preserve focus on that surface. +2. Every control earns its position: placement, grouping, and hierarchy should reveal why something is available at that moment. +3. Fast beats ornate: common actions should be quick to find, quick to trigger, and visually stable. +4. Refined density: the app can be tool-rich, but it should stay orderly, scannable, and calm. +5. Tactical clarity: markings, agents, abilities, and annotations should be legible before they are decorative. + +## Accessibility & Inclusion + +No formal accessibility target is currently defined. Use sensible product defaults: clear focus states, keyboard-friendly controls where practical, readable contrast in the dark interface, reduced-motion respect for nonessential transitions, and color choices that do not make tactical meaning depend on hue alone. diff --git a/README.md b/README.md index 87d4f07f..c765a551 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,22 @@ flutter pub get flutter run ``` +### Windows dev OAuth callbacks +Discord OAuth redirects back into the desktop app through the `icarus://auth/callback` +protocol. On Windows, the installed app normally owns that protocol handler, so a +dev build may not receive the browser callback. + +For a temporary dev session, run the Windows build with the force protocol +registration flag: + +```powershell +fvm flutter run -d windows --dart-define=ICARUS_FORCE_PROTOCOL_REGISTER=true +``` + +This rewrites the current user's `icarus://` handler to the debug executable. +After testing OAuth, launch the installed Icarus app once to restore the handler +back to the installed build. + ### Isolated Hive store Desktop builds accept an absolute `--hive-store-dir` path. It moves every @@ -68,9 +84,13 @@ support directory. Use a different absolute Hive directory for each instance. ## Build ```bash -flutter build +flutter build --dart-define=ICARUS_CLOUD_ENVIRONMENT=development ``` +That command makes an internal build against the named development Convex +deployment. Use the release scripts in `docs/release_process.md` for stable or +Store artifacts. They require explicit production cloud configuration. + ## Versioning (Windows MSIX) There is a helper script for bumping versions across `pubspec.yaml` and `lib/const/settings.dart`. diff --git a/analysis_options.yaml b/analysis_options.yaml index c5107358..e3fcd287 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -17,6 +17,9 @@ analyzer: - windows/** - macos/** - linux/** + - third_party/** + - tool/convex_client_gauntlet/** + - tool/icarus_convex_codegen/** errors: curly_braces_in_flow_control_structures: ignore include: package:flutter_lints/flutter.yaml diff --git a/assets/icarus-icon.webp b/assets/icarus-icon.webp new file mode 100644 index 00000000..e77acefc Binary files /dev/null and b/assets/icarus-icon.webp differ diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts new file mode 100644 index 00000000..a7f2c260 --- /dev/null +++ b/convex/_generated/api.d.ts @@ -0,0 +1,109 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type * as crons from "../crons.js"; +import type * as elements from "../elements.js"; +import type * as folders from "../folders.js"; +import type * as health from "../health.js"; +import type * as images from "../images.js"; +import type * as invites from "../invites.js"; +import type * as lib_assetReferences from "../lib/assetReferences.js"; +import type * as lib_auth from "../lib/auth.js"; +import type * as lib_canonicalValues from "../lib/canonicalValues.js"; +import type * as lib_cloudProtocol from "../lib/cloudProtocol.js"; +import type * as lib_entities from "../lib/entities.js"; +import type * as lib_errors from "../lib/errors.js"; +import type * as lib_imageAssets from "../lib/imageAssets.js"; +import type * as lib_lineupItems from "../lib/lineupItems.js"; +import type * as lib_opTypes from "../lib/opTypes.js"; +import type * as lib_payloadValidators from "../lib/payloadValidators.js"; +import type * as lib_profile from "../lib/profile.js"; +import type * as lib_publicValidators from "../lib/publicValidators.js"; +import type * as lib_r2 from "../lib/r2.js"; +import type * as lib_snapshotSerialization from "../lib/snapshotSerialization.js"; +import type * as lib_strategyAgentSummary from "../lib/strategyAgentSummary.js"; +import type * as lineups from "../lineups.js"; +import type * as maintenance from "../maintenance.js"; +import type * as ops from "../ops.js"; +import type * as page from "../page.js"; +import type * as pages from "../pages.js"; +import type * as presence from "../presence.js"; +import type * as shares from "../shares.js"; +import type * as strategies from "../strategies.js"; +import type * as strategy from "../strategy.js"; +import type * as users from "../users.js"; + +import type { + ApiFromModules, + FilterApi, + FunctionReference, +} from "convex/server"; + +declare const fullApi: ApiFromModules<{ + crons: typeof crons; + elements: typeof elements; + folders: typeof folders; + health: typeof health; + images: typeof images; + invites: typeof invites; + "lib/assetReferences": typeof lib_assetReferences; + "lib/auth": typeof lib_auth; + "lib/canonicalValues": typeof lib_canonicalValues; + "lib/cloudProtocol": typeof lib_cloudProtocol; + "lib/entities": typeof lib_entities; + "lib/errors": typeof lib_errors; + "lib/imageAssets": typeof lib_imageAssets; + "lib/lineupItems": typeof lib_lineupItems; + "lib/opTypes": typeof lib_opTypes; + "lib/payloadValidators": typeof lib_payloadValidators; + "lib/profile": typeof lib_profile; + "lib/publicValidators": typeof lib_publicValidators; + "lib/r2": typeof lib_r2; + "lib/snapshotSerialization": typeof lib_snapshotSerialization; + "lib/strategyAgentSummary": typeof lib_strategyAgentSummary; + lineups: typeof lineups; + maintenance: typeof maintenance; + ops: typeof ops; + page: typeof page; + pages: typeof pages; + presence: typeof presence; + shares: typeof shares; + strategies: typeof strategies; + strategy: typeof strategy; + users: typeof users; +}>; + +/** + * A utility for referencing Convex functions in your app's public API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export declare const api: FilterApi< + typeof fullApi, + FunctionReference +>; + +/** + * A utility for referencing Convex functions in your app's internal API. + * + * Usage: + * ```js + * const myFunctionReference = internal.myModule.myFunction; + * ``` + */ +export declare const internal: FilterApi< + typeof fullApi, + FunctionReference +>; + +export declare const components: {}; diff --git a/convex/_generated/api.js b/convex/_generated/api.js new file mode 100644 index 00000000..44bf9858 --- /dev/null +++ b/convex/_generated/api.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +/** + * Generated `api` utility. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { anyApi, componentsGeneric } from "convex/server"; + +/** + * A utility for referencing Convex functions in your app's API. + * + * Usage: + * ```js + * const myFunctionReference = api.myModule.myFunction; + * ``` + */ +export const api = anyApi; +export const internal = anyApi; +export const components = componentsGeneric(); diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts new file mode 100644 index 00000000..f97fd194 --- /dev/null +++ b/convex/_generated/dataModel.d.ts @@ -0,0 +1,60 @@ +/* eslint-disable */ +/** + * Generated data model types. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import type { + DataModelFromSchemaDefinition, + DocumentByName, + TableNamesInDataModel, + SystemTableNames, +} from "convex/server"; +import type { GenericId } from "convex/values"; +import schema from "../schema.js"; + +/** + * The names of all of your Convex tables. + */ +export type TableNames = TableNamesInDataModel; + +/** + * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Doc = DocumentByName< + DataModel, + TableName +>; + +/** + * An identifier for a document in Convex. + * + * Convex documents are uniquely identified by their `Id`, which is accessible + * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). + * + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. + * + * IDs are just strings at runtime, but this type can be used to distinguish them from other + * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). + */ +export type Id = + GenericId; + +/** + * A type describing your Convex data model. + * + * This type includes information about what tables you have, the type of + * documents stored in those tables, and the indexes defined on them. + * + * This type is used to parameterize methods like `queryGeneric` and + * `mutationGeneric` to make them type-safe. + */ +export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts new file mode 100644 index 00000000..f235db4a --- /dev/null +++ b/convex/_generated/server.d.ts @@ -0,0 +1,162 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + ActionBuilder, + HttpActionBuilder, + MutationBuilder, + QueryBuilder, + GenericActionCtx, + GenericMutationCtx, + GenericQueryCtx, + GenericDatabaseReader, + GenericDatabaseWriter, +} from "convex/server"; +import type { DataModel } from "./dataModel.js"; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +type Env = { + readonly CONVEX_CLOUD_URL: string; + readonly CONVEX_SITE_URL: string; +}; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const query: QueryBuilder; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export declare const internalQuery: QueryBuilder; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const mutation: MutationBuilder; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export declare const internalMutation: MutationBuilder; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export declare const action: ActionBuilder; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export declare const internalAction: ActionBuilder; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export declare const httpAction: HttpActionBuilder; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export declare const env: Env; + +/** + * A set of services for use within Convex query functions. + * + * The query context is passed as the first argument to any Convex query + * function run on the server. + * + * This differs from the {@link MutationCtx} because all of the services are + * read-only. + */ +export type QueryCtx = GenericQueryCtx; + +/** + * A set of services for use within Convex mutation functions. + * + * The mutation context is passed as the first argument to any Convex mutation + * function run on the server. + */ +export type MutationCtx = GenericMutationCtx; + +/** + * A set of services for use within Convex action functions. + * + * The action context is passed as the first argument to any Convex action + * function run on the server. + */ +export type ActionCtx = GenericActionCtx; + +/** + * An interface to read from the database within Convex query functions. + * + * The two entry points are {@link DatabaseReader.get}, which fetches a single + * document by its {@link Id}, or {@link DatabaseReader.query}, which starts + * building a query. + */ +export type DatabaseReader = GenericDatabaseReader; + +/** + * An interface to read from and write to the database within Convex mutation + * functions. + * + * Convex guarantees that all writes within a single mutation are + * executed atomically, so you never have to worry about partial writes leaving + * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) + * for the guarantees Convex provides your functions. + */ +export type DatabaseWriter = GenericDatabaseWriter; diff --git a/convex/_generated/server.js b/convex/_generated/server.js new file mode 100644 index 00000000..2dbe0dbd --- /dev/null +++ b/convex/_generated/server.js @@ -0,0 +1,101 @@ +/* eslint-disable */ +/** + * Generated utilities for implementing server-side Convex query and mutation functions. + * + * THIS CODE IS AUTOMATICALLY GENERATED. + * + * To regenerate, run `npx convex dev`. + * @module + */ + +import { + actionGeneric, + httpActionGeneric, + queryGeneric, + mutationGeneric, + internalActionGeneric, + internalMutationGeneric, + internalQueryGeneric, +} from "convex/server"; + +/** + * Define a query in this Convex app's public API. + * + * This function will be allowed to read your Convex database and will be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const query = queryGeneric; + +/** + * Define a query that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to read from your Convex database. It will not be accessible from the client. + * + * @param func - The query function. It receives a {@link QueryCtx} as its first argument. + * @returns The wrapped query. Include this as an `export` to name it and make it accessible. + */ +export const internalQuery = internalQueryGeneric; + +/** + * Define a mutation in this Convex app's public API. + * + * This function will be allowed to modify your Convex database and will be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const mutation = mutationGeneric; + +/** + * Define a mutation that is only accessible from other Convex functions (but not from the client). + * + * This function will be allowed to modify your Convex database. It will not be accessible from the client. + * + * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. + * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. + */ +export const internalMutation = internalMutationGeneric; + +/** + * Define an action in this Convex app's public API. + * + * An action is a function which can execute any JavaScript code, including non-deterministic + * code and code with side-effects, like calling third-party services. + * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. + * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. + * + * @param func - The action. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped action. Include this as an `export` to name it and make it accessible. + */ +export const action = actionGeneric; + +/** + * Define an action that is only accessible from other Convex functions (but not from the client). + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument. + * @returns The wrapped function. Include this as an `export` to name it and make it accessible. + */ +export const internalAction = internalActionGeneric; + +/** + * Define an HTTP action. + * + * The wrapped function will be used to respond to HTTP requests received + * by a Convex deployment if the requests matches the path and method where + * this action is routed. Be sure to route your httpAction in `convex/http.js`. + * + * @param func - The function. It receives an {@link ActionCtx} as its first argument + * and a Fetch API `Request` object as its second. + * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. + */ +export const httpAction = httpActionGeneric; + +/** + * Typesafe environment variables. + * + * This includes platform-provided env vars and any variables declared in + * `convex.config.ts`. + */ +export const env = process.env; diff --git a/convex/accessControl.test.ts b/convex/accessControl.test.ts new file mode 100644 index 00000000..c1ebc46d --- /dev/null +++ b/convex/accessControl.test.ts @@ -0,0 +1,586 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createFolder = makeFunctionReference<"mutation">("folders:create"); +const updateFolder = makeFunctionReference<"mutation">("folders:update"); +const listFolderTree = makeFunctionReference<"query">("folders:listTree"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const updateStrategy = makeFunctionReference<"mutation">("strategies:update"); +const moveStrategy = makeFunctionReference<"mutation">("strategies:move"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); +const listStrategies = makeFunctionReference<"query">( + "strategies:listForFolder", +); +const getStrategyShell = makeFunctionReference<"query">("strategy:getShell"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); +const revokeShare = makeFunctionReference<"mutation">("shares:revoke"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +function identity(subject: "a" | "b" | "c") { + return { + issuer: "https://access-control.test", + subject, + tokenIdentifier: `access-control|${subject}`, + name: `User ${subject.toUpperCase()}`, + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + a: Harness; + b: Harness; + c: Harness; +}> { + const t = convexTest(schema, modules); + const a = t.withIdentity(identity("a")); + const b = t.withIdentity(identity("b")); + const c = t.withIdentity(identity("c")); + await Promise.all([ + a.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), + b.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), + c.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }), + ]); + return { t, a, b, c }; +} + +async function seedStrategy( + owner: Harness, + strategyPublicId: string, + pagePublicId: string, + folderPublicId?: string, +) { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "A private Strategy", + mapData: "ascent", + folderPublicId, + initialPagePublicId: pagePublicId, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); +} + +describe("A/B/C access boundary", () => { + test("a private Strategy stays hidden until a link is redeemed", async () => { + const { a, b, c } = await createHarness(); + const strategyPublicId = "private-strategy"; + const initialPagePublicId = "private-page"; + await seedStrategy(a, strategyPublicId, initialPagePublicId); + + await expect( + b.query(listStrategies, { scope: "shared" }), + ).resolves.toEqual([]); + await expect( + c.query(listStrategies, { scope: "shared" }), + ).resolves.toEqual([]); + await expect( + b.query(getStrategyShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).rejects.toThrow("Forbidden"); + await expect( + c.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).rejects.toThrow("Forbidden"); + + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "strategy-viewer-token", + role: "viewer", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-viewer-token", + }); + + await expect( + b.query(getStrategyShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).resolves.toMatchObject({ + header: { publicId: strategyPublicId, role: "viewer" }, + pages: [{ publicId: initialPagePublicId }], + }); + await expect( + b.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).resolves.toMatchObject({ + header: { publicId: strategyPublicId, role: "viewer" }, + pages: [{ publicId: initialPagePublicId }], + }); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + name: "Viewer must not write", + }), + ).rejects.toThrow("Forbidden"); + await expect( + b.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: "viewer-page", + name: "Viewer page", + sortIndex: 1, + isAttack: false, + }), + ).rejects.toThrow("Forbidden"); + + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "strategy-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-editor-token", + }); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + name: "Edited by B", + }), + ).resolves.toMatchObject({ ok: true, revision: 1 }); + await expect( + b.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 1, + pagePublicId: "editor-page", + name: "Added by B", + sortIndex: 1, + isAttack: false, + }), + ).resolves.toMatchObject({ ok: true, revision: 2 }); + await expect( + a.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).resolves.toMatchObject({ + header: { name: "Edited by B", revision: 2, role: "owner" }, + pages: [ + { publicId: initialPagePublicId }, + { publicId: "editor-page" }, + ], + }); + + await a.mutation(revokeShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "strategy-editor-token", + }); + await expect( + c.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "strategy-editor-token", + }), + ).rejects.toThrow("Share link revoked"); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 2, + name: "B keeps redeemed access", + }), + ).resolves.toMatchObject({ ok: true, revision: 3 }); + }); + + test("redeeming reports whether the link granted anything new", async () => { + const { a, b } = await createHarness(); + const strategyPublicId = "already-accessible"; + await seedStrategy(a, strategyPublicId, "already-page"); + const share = async (token: string, role: "viewer" | "editor") => + a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token, + role, + }); + const redeem = (who: Harness, token: string) => + who.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token, + }); + await share("viewer-token", "viewer"); + await share("editor-token", "editor"); + + // The owner opening their own link gains nothing. + await expect(redeem(a, "viewer-token")).resolves.toMatchObject({ + targetType: "strategy", + strategyPublicId, + role: "owner", + alreadyHadAccess: true, + }); + // A first redemption grants access; repeating it grants nothing. + await expect(redeem(b, "viewer-token")).resolves.toMatchObject({ + role: "viewer", + alreadyHadAccess: false, + }); + await expect(redeem(b, "viewer-token")).resolves.toMatchObject({ + role: "viewer", + alreadyHadAccess: true, + }); + // A higher role is new; a lower one after it is not. + await expect(redeem(b, "editor-token")).resolves.toMatchObject({ + role: "editor", + alreadyHadAccess: false, + }); + await expect(redeem(b, "viewer-token")).resolves.toMatchObject({ + role: "editor", + alreadyHadAccess: true, + }); + + // Folders report the same way. + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: "owned-folder", + name: "Owned", + }); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: "owned-folder", + token: "folder-token", + role: "viewer", + }); + await expect(redeem(a, "folder-token")).resolves.toMatchObject({ + targetType: "folder", + role: "owner", + alreadyHadAccess: true, + }); + await expect(redeem(b, "folder-token")).resolves.toMatchObject({ + role: "viewer", + alreadyHadAccess: false, + }); + }); + + test("folder roles inherit through descendants without granting ownership", async () => { + const { a, b, c } = await createHarness(); + const rootFolderPublicId = "shared-root"; + const childFolderPublicId = "shared-child"; + const strategyPublicId = "nested-strategy"; + + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: rootFolderPublicId, + name: "A root", + }); + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: childFolderPublicId, + name: "A child", + parentFolderPublicId: rootFolderPublicId, + }); + await seedStrategy( + a, + strategyPublicId, + "nested-page", + childFolderPublicId, + ); + + await expect( + b.query(listFolderTree, { scope: "shared" }), + ).resolves.toEqual([]); + await expect( + b.query(getStrategyShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).rejects.toThrow("Forbidden"); + + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: rootFolderPublicId, + token: "folder-viewer-token", + role: "viewer", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-viewer-token", + }); + + await expect( + b.query(listFolderTree, { scope: "shared" }), + ).resolves.toMatchObject([ + { publicId: rootFolderPublicId, role: "viewer" }, + { + publicId: childFolderPublicId, + parentFolderPublicId: rootFolderPublicId, + role: "viewer", + }, + ]); + await expect( + b.query(listStrategies, { + folderPublicId: childFolderPublicId, + scope: "shared", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, role: "viewer" }, + ]); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + name: "Viewer must not write", + }), + ).rejects.toThrow("Forbidden"); + await expect( + b.mutation(updateFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + folderPublicId: childFolderPublicId, + name: "Viewer must not rename", + }), + ).rejects.toThrow("Forbidden"); + + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: rootFolderPublicId, + token: "folder-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-editor-token", + }); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + name: "Inherited editor write", + }), + ).resolves.toMatchObject({ ok: true, revision: 1 }); + await expect( + b.mutation(updateFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + folderPublicId: rootFolderPublicId, + name: "Editors are not owners", + }), + ).rejects.toThrow("Forbidden"); + await expect( + b.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: rootFolderPublicId, + token: "editor-cannot-reshare", + role: "viewer", + }), + ).rejects.toThrow("Forbidden"); + + await a.mutation(revokeShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: rootFolderPublicId, + token: "folder-editor-token", + }); + await expect( + c.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "folder-editor-token", + }), + ).rejects.toThrow("Share link revoked"); + await expect( + c.query(listFolderTree, { scope: "shared" }), + ).resolves.toEqual([]); + await expect( + b.mutation(updateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 1, + name: "Redeemed editor remains durable", + }), + ).resolves.toMatchObject({ ok: true, revision: 2 }); + }); + + test("only the owner can move a directly shared Strategy", async () => { + const { a, b } = await createHarness(); + const sourceFolderPublicId = "direct-move-source"; + const targetFolderPublicId = "direct-move-target"; + const strategyPublicId = "directly-shared-strategy"; + + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: sourceFolderPublicId, + name: "Source", + }); + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: targetFolderPublicId, + name: "Target", + }); + await seedStrategy( + a, + strategyPublicId, + "directly-shared-page", + sourceFolderPublicId, + ); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "direct-move-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "direct-move-editor-token", + }); + + await expect( + b.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + folderPublicId: targetFolderPublicId, + }), + ).rejects.toThrow("Forbidden"); + await expect( + a.query(listStrategies, { + folderPublicId: sourceFolderPublicId, + scope: "owned", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, revision: 0 }, + ]); + + await expect( + a.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + folderPublicId: targetFolderPublicId, + }), + ).resolves.toMatchObject({ ok: true, revision: 1 }); + await expect( + a.query(listStrategies, { + folderPublicId: targetFolderPublicId, + scope: "owned", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, revision: 1 }, + ]); + }); + + test("an inherited folder editor cannot move a Strategy out of the share", async () => { + const { a, b } = await createHarness(); + const sharedFolderPublicId = "inherited-move-source"; + const strategyPublicId = "inherited-editor-strategy"; + + await a.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: sharedFolderPublicId, + name: "Shared source", + }); + await seedStrategy( + a, + strategyPublicId, + "inherited-editor-page", + sharedFolderPublicId, + ); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "folder", + targetPublicId: sharedFolderPublicId, + token: "inherited-move-editor-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "inherited-move-editor-token", + }); + + await expect( + b.mutation(moveStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }), + ).rejects.toThrow("Forbidden"); + await expect( + b.query(listStrategies, { + folderPublicId: sharedFolderPublicId, + scope: "shared", + }), + ).resolves.toMatchObject([ + { publicId: strategyPublicId, role: "editor", revision: 0 }, + ]); + }); + + test("deleting a Strategy removes its access records", async () => { + const { t, a, b } = await createHarness(); + const strategyPublicId = "deleted-strategy"; + await seedStrategy(a, strategyPublicId, "deleted-page"); + await a.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "deleted-strategy-token", + role: "editor", + }); + await b.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "deleted-strategy-token", + }); + + await a.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + + const accessRecordCounts = await t.run(async (ctx) => ({ + collaborators: (await ctx.db.query("strategyCollaborators").collect()) + .length, + shareLinks: (await ctx.db.query("shareLinks").collect()).length, + })); + expect(accessRecordCounts).toEqual({ collaborators: 0, shareLinks: 0 }); + }); +}); diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 00000000..f49f1417 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,13 @@ +import type { AuthConfig } from "convex/server"; + +export default { + providers: [ + { + type: "customJwt", + applicationID: "authenticated", + issuer: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1", + jwks: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1/.well-known/jwks.json", + algorithm: "ES256", + }, + ], +} satisfies AuthConfig; diff --git a/convex/cloudProtocol.test.ts b/convex/cloudProtocol.test.ts new file mode 100644 index 00000000..c9edd81f --- /dev/null +++ b/convex/cloudProtocol.test.ts @@ -0,0 +1,372 @@ +import { convexTest } from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const publicMutations = [ + ["folders:create", { publicId: "folder", name: "Folder" }], + ["folders:update", { folderPublicId: "folder" }], + ["folders:move", { folderPublicId: "folder" }], + ["folders:delete", { folderPublicId: "folder" }], + [ + "invites:create", + { strategyPublicId: "strategy", token: "token", role: "viewer" }, + ], + ["invites:redeem", { token: "token" }], + ["invites:revoke", { strategyPublicId: "strategy", token: "token" }], + [ + "ops:applyBatch", + { strategyPublicId: "strategy", clientId: "client", ops: [] }, + ], + [ + "pages:add", + { + strategyPublicId: "strategy", + expectedRevision: 0, + pagePublicId: "page", + name: "Page", + sortIndex: 0, + isAttack: true, + }, + ], + [ + "pages:rename", + { + strategyPublicId: "strategy", + pagePublicId: "page", + name: "Page", + expectedRevision: 0, + }, + ], + [ + "pages:delete", + { + strategyPublicId: "strategy", + pagePublicId: "page", + expectedRevision: 0, + }, + ], + [ + "pages:reorder", + { + strategyPublicId: "strategy", + orderedPagePublicIds: [], + expectedRevision: 0, + }, + ], + [ + "shares:create", + { + targetType: "strategy", + targetPublicId: "strategy", + token: "token", + role: "viewer", + }, + ], + [ + "shares:revoke", + { targetType: "strategy", targetPublicId: "strategy", token: "token" }, + ], + ["shares:redeem", { token: "token" }], + [ + "strategies:create", + { publicId: "strategy", name: "Strategy", mapData: "ascent" }, + ], + [ + "strategies:createWithInitialPage", + { + publicId: "strategy", + name: "Strategy", + mapData: "ascent", + initialPagePublicId: "page", + initialPageName: "Page 1", + initialPageIsAttack: true, + }, + ], + [ + "strategies:duplicate", + { + sourceStrategyPublicId: "strategy", + publicId: "copy", + name: "Strategy (Copy)", + }, + ], + [ + "strategies:update", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + [ + "strategies:move", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + [ + "strategies:delete", + { strategyPublicId: "strategy", expectedRevision: 0 }, + ], + ["users:ensureCurrentUser", {}], +] as const; + +// Every public read that returns element or lineup payloads, and the shell. +const publicPayloadQueries = [ + ["strategy:getShell", { strategyPublicId: "strategy" }], + [ + "strategy:getFullSnapshot", + { strategyPublicId: "strategy", acceptsTrashedPagesLeftOut: true }, + ], + ["page:getSnapshot", { strategyPublicId: "strategy", pagePublicId: "page" }], + [ + "elements:listForPage", + { strategyPublicId: "strategy", pagePublicId: "page" }, + ], + ["elements:listForStrategy", { strategyPublicId: "strategy" }], + [ + "lineups:listForPage", + { strategyPublicId: "strategy", pagePublicId: "page" }, + ], + ["lineups:listForStrategy", { strategyPublicId: "strategy" }], +] as const; + +const publicWriteActions = [ + [ + "images:generateUploadUrl", + { + strategyPublicId: "strategy", + assetPublicId: "asset", + mimeType: "image/png", + fileExtension: ".png", + }, + ], + [ + "images:completeUpload", + { strategyPublicId: "strategy", assetPublicId: "asset" }, + ], + [ + "images:deleteAssetRef", + { strategyPublicId: "strategy", assetPublicId: "asset" }, + ], +] as const; + +async function captureError(promise: Promise) { + return promise.then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); +} + +function expectUpgradeRequired(error: { data?: unknown } | null): void { + expect(error).not.toBeNull(); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); +} + +describe("public cloud mutation protocol gate", () => { + test.each(publicMutations)("%s rejects an old protocol canonically", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const mutation = makeFunctionReference<"mutation">(identifier); + const error = await t + .mutation(mutation, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); + + expect(error).not.toBeNull(); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); + }); + + test("a newer unknown protocol receives the same canonical error", async () => { + const t = convexTest(schema, modules); + const mutation = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", + ); + const error = await t + .mutation(mutation, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION + 1, + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); + + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); + }); +}); + +describe("public payload query protocol gate", () => { + // A client from before a cutover sends no protocol on these reads; one on + // an older protocol sends its own. Neither may see a row it cannot read. + test.each(publicPayloadQueries)("%s rejects a missing protocol", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const query = makeFunctionReference<"query">(identifier); + + const error = await captureError(t.query(query, args)); + + expect(error).not.toBeNull(); + expect(String(error)).toContain("clientProtocolVersion"); + }); + + test.each(publicPayloadQueries)("%s rejects an old protocol canonically", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const query = makeFunctionReference<"query">(identifier); + + const error = await captureError( + t.query(query, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }), + ); + + expectUpgradeRequired(error); + }); + + test.each(publicPayloadQueries)( + "%s rejects an unknown future protocol canonically", + async (identifier, args) => { + const t = convexTest(schema, modules); + const query = makeFunctionReference<"query">(identifier); + + const error = await captureError( + t.query(query, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION + 1, + }), + ); + + expectUpgradeRequired(error); + }, + ); + + test.each(publicPayloadQueries)("%s answers the current protocol", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const owner = t.withIdentity({ + issuer: "https://cloud-protocol.test", + subject: "owner", + tokenIdentifier: "cloud-protocol|owner", + name: "owner", + }); + const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; + await owner.mutation( + makeFunctionReference<"mutation">("users:ensureCurrentUser"), + protocol, + ); + await owner.mutation( + makeFunctionReference<"mutation">("strategies:createWithInitialPage"), + { + ...protocol, + publicId: "strategy", + name: "Strategy", + mapData: "ascent", + initialPagePublicId: "page", + initialPageName: "Page 1", + initialPageIsAttack: true, + }, + ); + const query = makeFunctionReference<"query">(identifier); + + await expect( + owner.query(query, { ...args, ...protocol }), + ).resolves.toBeDefined(); + }); +}); + +describe("the protocol check a refused client asks", () => { + const ping = makeFunctionReference<"query">("health:ping"); + + test("the connection health check sends no protocol and is answered", async () => { + const t = convexTest(schema, modules); + await expect(t.query(ping, {})).resolves.toBe("ok"); + }); + + test("the current protocol is accepted", async () => { + const t = convexTest(schema, modules); + await expect( + t.query(ping, { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }), + ).resolves.toBe("ok"); + }); + + test("an old protocol is refused canonically", async () => { + const t = convexTest(schema, modules); + expectUpgradeRequired( + await captureError( + t.query(ping, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }), + ), + ); + }); +}); + +describe("public cloud write action protocol gate", () => { + test.each(publicWriteActions)("%s rejects a missing protocol", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError(t.action(action, args)); + + expect(error).not.toBeNull(); + }); + + test.each(publicWriteActions)("%s rejects an old protocol canonically", async ( + identifier, + args, + ) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError( + t.action(action, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION - 1, + }), + ); + + expectUpgradeRequired(error); + }); + + test.each(publicWriteActions)( + "%s rejects an unknown future protocol canonically", + async (identifier, args) => { + const t = convexTest(schema, modules); + const action = makeFunctionReference<"action">(identifier); + + const error = await captureError( + t.action(action, { + ...args, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION + 1, + }), + ); + + expectUpgradeRequired(error); + }, + ); +}); diff --git a/convex/crons.ts b/convex/crons.ts new file mode 100644 index 00000000..fb401e31 --- /dev/null +++ b/convex/crons.ts @@ -0,0 +1,54 @@ +import { cronJobs } from "convex/server"; +import { + purgeOldOperationEventsRef, + purgeOldTombstonesRef, + purgeTrashedPagesRef, +} from "./maintenance"; +import { + markStaleImageUploadsDeletedRef, + processAssetReclaimCandidatesRef, + sweepDeletedImageAssetsRef, +} from "./images"; + +const crons = cronJobs(); + +crons.interval( + "purge-operation-events", + { hours: 24 }, + purgeOldOperationEventsRef, + {}, +); +crons.interval( + "purge-tombstones", + { hours: 24 }, + purgeOldTombstonesRef, + {}, +); +crons.interval( + "purge-trashed-pages", + { hours: 24 }, + purgeTrashedPagesRef, + {}, +); +crons.interval( + "mark-stale-image-uploads-deleted", + { hours: 1 }, + markStaleImageUploadsDeletedRef, + {}, +); +// Purges start the reclaim worker themselves; this picks up any batch a +// failed run left queued. +crons.interval( + "process-asset-reclaim-candidates", + { hours: 1 }, + processAssetReclaimCandidatesRef, + {}, +); +crons.interval( + "sweep-deleted-image-assets", + { hours: 1 }, + sweepDeletedImageAssetsRef, + {}, +); + +export default crons; diff --git a/convex/elements.ts b/convex/elements.ts new file mode 100644 index 00000000..3a88897b --- /dev/null +++ b/convex/elements.ts @@ -0,0 +1,91 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { + elementsOnPages, + getPageByPublicId, + getStrategyByPublicId, + listLivePages, +} from "./lib/entities"; +import { errorWithCode } from "./lib/errors"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { elementValidator } from "./lib/publicValidators"; + +export const listForPage = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + returns: v.array(elementValidator), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + + return elements + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((element) => ({ + publicId: element.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: page.publicId, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + createdAt: element.createdAt, + updatedAt: element.updatedAt, + })); + }, +}); + +export const listForStrategy = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + }, + returns: v.array(elementValidator), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const pages = await listLivePages(ctx, strategy._id); + const pagePublicIds = new Map( + pages.map((page) => [page._id, page.publicId]), + ); + + // Page by page: content in the trash is never read. + const elements = await elementsOnPages(ctx, pages); + + return elements + .sort((a, b) => a.sortIndex - b.sortIndex) + .filter((element) => pagePublicIds.has(element.pageId)) + .map((element) => ({ + publicId: element.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: pagePublicIds.get(element.pageId)!, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + createdAt: element.createdAt, + updatedAt: element.updatedAt, + })); + }, +}); diff --git a/convex/error_codes.json b/convex/error_codes.json new file mode 100644 index 00000000..0ea83da9 --- /dev/null +++ b/convex/error_codes.json @@ -0,0 +1,39 @@ +[ + "CLIENT_UPGRADE_REQUIRED", + "CONFLICT", + "ELEMENT_STRATEGY_MISMATCH", + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "FORBIDDEN", + "INTERNAL_ERROR", + "INVALID_ELEMENT_PAYLOAD_DATA", + "INVALID_ELEMENT_PAYLOAD_KIND", + "INVALID_ELEMENT_PAYLOAD_VERSION", + "INVALID_LINEUP_PAYLOAD_DATA", + "INVALID_LINEUP_PAYLOAD_KIND", + "INVALID_LINEUP_PAYLOAD_VERSION", + "INVALID_OP", + "INVALID_PAGE_CONTENT_COUNT", + "INVALID_PAYLOAD", + "INVITE_EXPIRED", + "INVITE_REVOKED", + "LINEUP_PAGE_MISMATCH", + "LINEUP_STRATEGY_MISMATCH", + "MISSING_ADD_ELEMENT_ARGS", + "MISSING_ADD_LINEUP_ARGS", + "MISSING_ELEMENT_PAYLOAD", + "MISSING_ENTITY_PUBLIC_ID", + "MISSING_LINEUP_PAYLOAD", + "MISSING_PAGE_ID", + "MISSING_PAGE_PUBLIC_ID", + "NOT_FOUND", + "PAGE_DELETED", + "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP", + "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + "PAGE_STRATEGY_MISMATCH", + "R2_OBJECT_KEY_MISMATCH", + "SHARE_LINK_REVOKED", + "STRATEGY_TOO_LARGE_TO_DUPLICATE", + "UNAUTHENTICATED", + "UNSUPPORTED_OP", + "UPLOAD_INTENT_NOT_FOUND" +] diff --git a/convex/folders.test.ts b/convex/folders.test.ts new file mode 100644 index 00000000..72b9d079 --- /dev/null +++ b/convex/folders.test.ts @@ -0,0 +1,207 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { expect, test } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createFolder = makeFunctionReference<"mutation">("folders:create"); +const moveFolder = makeFunctionReference<"mutation">("folders:move"); + +const identity = { + issuer: "https://folders.test", + subject: "owner", + tokenIdentifier: "folders|owner", + name: "Folder Owner", +}; + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + const owner = t.withIdentity(identity); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + return { t, owner }; +} + +async function seedFolder( + owner: Harness, + publicId: string, + parentFolderPublicId?: string, +) { + await owner.mutation(createFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId, + name: publicId, + parentFolderPublicId, + }); +} + +async function parentPublicId(t: RootHarness, publicId: string) { + return await t.run(async (ctx) => { + const folder = await ctx.db + .query("folders") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .unique(); + if (folder === null) { + throw new Error(`Missing folder ${publicId}`); + } + if (folder.parentFolderId === undefined) { + return null; + } + return (await ctx.db.get(folder.parentFolderId))?.publicId ?? null; + }); +} + +test("folder move rejects self-parent without changing the folder", async () => { + const { t, owner } = await createHarness(); + await seedFolder(owner, "root"); + + await expect( + owner.mutation(moveFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + folderPublicId: "root", + parentFolderPublicId: "root", + }), + ).rejects.toThrow("Folder move would create a cycle"); + + await expect(parentPublicId(t, "root")).resolves.toBeNull(); +}); + +test("folder move rejects a descendant parent without changing the tree", async () => { + const { t, owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedFolder(owner, "child", "root"); + await seedFolder(owner, "grandchild", "child"); + + await expect( + owner.mutation(moveFolder, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + folderPublicId: "root", + parentFolderPublicId: "grandchild", + }), + ).rejects.toThrow("Folder move would create a cycle"); + + await expect(parentPublicId(t, "root")).resolves.toBeNull(); + await expect(parentPublicId(t, "child")).resolves.toBe("root"); + await expect(parentPublicId(t, "grandchild")).resolves.toBe("child"); +}); + +const listFolderTree = makeFunctionReference<"query">("folders:listTree"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); + +async function seedStrategy( + owner: Harness, + publicId: string, + folderPublicId: string, + mapData: string, +) { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId, + name: publicId, + mapData, + folderPublicId, + initialPagePublicId: `${publicId}-page`, + initialPageName: "Page 1", + initialPageIsAttack: true, + initialPageSettings: { + agentSize: 48, + abilitySize: 32, + useNeutralTeamColors: false, + }, + }); +} + +async function placeAgent( + owner: Harness, + strategyPublicId: string, + elementPublicId: string, + agentType: string, +) { + await owner.mutation(applyBatch, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + clientId: "client-a", + ops: [ + { + opId: `${elementPublicId}-add`, + type: "element.add", + elementPublicId, + pagePublicId: `${strategyPublicId}-page`, + payload: { + kind: "agent", + payloadVersion: 1, + data: { type: agentType, position: { x: 0.5, y: 0.5 } }, + }, + sortIndex: 0, + }, + ], + }); +} + +test("folder tree summarises strategies, maps, and agents across the subtree", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedFolder(owner, "child", "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await seedStrategy(owner, "s-child-1", "child", "haven"); + await seedStrategy(owner, "s-child-2", "child", "haven"); + await placeAgent(owner, "s-root", "e1", "jett"); + await placeAgent(owner, "s-child-1", "e2", "sova"); + await placeAgent(owner, "s-child-2", "e3", "sova"); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + const byId = new Map(tree.map((entry) => [entry.publicId, entry])); + expect(byId.get("child")).toMatchObject({ + strategyCount: 2, + mapPeeks: ["haven"], + agentTypes: ["sova"], + }); + expect(byId.get("root")).toMatchObject({ + strategyCount: 3, + mapPeeks: ["haven", "ascent"], + agentTypes: ["sova", "jett"], + }); +}); + +test("deleting a strategy drops it from the folder summary", async () => { + const { owner } = await createHarness(); + await seedFolder(owner, "root"); + await seedStrategy(owner, "s-root", "root", "ascent"); + await placeAgent(owner, "s-root", "e1", "jett"); + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "s-root", + expectedRevision: 0, + }); + + const tree = (await owner.query(listFolderTree, { scope: "owned" })) as Array< + Record + >; + expect(tree[0]).toMatchObject({ + strategyCount: 0, + mapPeeks: [], + agentTypes: [], + }); +}); diff --git a/convex/folders.ts b/convex/folders.ts new file mode 100644 index 00000000..14c0758b --- /dev/null +++ b/convex/folders.ts @@ -0,0 +1,489 @@ +import type { Doc, Id } from "./_generated/dataModel"; +import type { QueryCtx, MutationCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { + assertFolderRole, + getEffectiveFolderRoleForUser, + requireCurrentUser, +} from "./lib/auth"; +import { getFolderByPublicId } from "./lib/entities"; +import { readStrategyAgentTypes } from "./lib/strategyAgentSummary"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + conflictError, + forbiddenError, + invalidOpError, +} from "./lib/errors"; +import { + createResultValidator, + folderSummaryValidator, + okResultValidator, +} from "./lib/publicValidators"; + +type FolderScope = "owned" | "shared" | "all"; +type AnyCtx = QueryCtx | MutationCtx; + +function matchesScope( + ownerId: string, + userId: string, + scope: FolderScope, +): boolean { + if (scope === "all") { + return true; + } + if (scope === "owned") { + return ownerId === userId; + } + return ownerId !== userId; +} + +async function listAccessibleFoldersForScope( + ctx: AnyCtx, + userId: Id<"users">, + scope: FolderScope, +): Promise< + Array<{ folder: Doc<"folders">; role: "owner" | "editor" | "viewer" }> +> { + const candidates = new Map, Doc<"folders">>(); + + if (scope === "owned" || scope === "all") { + const owned = await ctx.db + .query("folders") + .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) + .collect(); + for (const folder of owned) { + candidates.set(folder._id, folder); + } + } + + if (scope === "shared" || scope === "all") { + const directShares = await ctx.db + .query("folderCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const queue = ( + await Promise.all(directShares.map((share) => ctx.db.get(share.folderId))) + ).filter( + (folder): folder is Doc<"folders"> => + folder !== null && folder.ownerId !== userId, + ); + + while (queue.length > 0) { + const folder = queue.shift()!; + if (candidates.has(folder._id)) { + continue; + } + candidates.set(folder._id, folder); + const children = await ctx.db + .query("folders") + .withIndex("by_parentFolderId", (q) => + q.eq("parentFolderId", folder._id), + ) + .collect(); + queue.push(...children.filter((child) => child.ownerId !== userId)); + } + } + + const results: Array<{ + folder: Doc<"folders">; + role: "owner" | "editor" | "viewer"; + }> = []; + + for (const folder of candidates.values()) { + const role = await getEffectiveFolderRoleForUser(ctx, folder, userId); + if (role === null) { + continue; + } + if (!matchesScope(folder.ownerId, userId, scope)) { + continue; + } + results.push({ folder, role }); + } + + return results; +} + +async function assertFolderMoveIsAcyclic( + ctx: MutationCtx, + folderId: Id<"folders">, + proposedParent: Doc<"folders">, +): Promise { + const visited = new Set>(); + let current = proposedParent; + + while (true) { + if (current._id === folderId) { + throw invalidOpError("Folder move would create a cycle"); + } + if (visited.has(current._id)) { + throw invalidOpError("Parent folder hierarchy contains a cycle"); + } + visited.add(current._id); + + if (current.parentFolderId === undefined) { + return; + } + const parent = await ctx.db.get(current.parentFolderId); + if (parent === null) { + throw invalidOpError("Parent folder hierarchy is invalid"); + } + current = parent; + } +} + +const folderScopeValidator = v.optional( + v.union(v.literal("owned"), v.literal("shared"), v.literal("all")), +); + +export const create = mutation({ + args: { + ...cloudProtocolArgs, + publicId: v.string(), + name: v.string(), + parentFolderPublicId: v.optional(v.string()), + iconId: v.optional(v.number()), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + }, + returns: createResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + const now = Date.now(); + + let parentFolderId: Id<"folders"> | undefined; + if (args.parentFolderPublicId !== undefined) { + const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); + const { role } = await assertFolderRole(ctx, parent, "owner"); + if (role !== "owner") { + throw forbiddenError(); + } + parentFolderId = parent._id; + } + + const existing = await ctx.db + .query("folders") + .withIndex("by_publicId", (q) => q.eq("publicId", args.publicId)) + .collect(); + const existingOwned = existing.find((item) => item.ownerId === user._id); + if (existingOwned !== undefined) { + return { ok: true, reused: true } as const; + } + if (existing.length > 0) { + throw conflictError(`Folder publicId already exists: ${args.publicId}`); + } + + await ctx.db.insert("folders", { + publicId: args.publicId, + ownerId: user._id, + name: args.name, + parentFolderId, + iconId: args.iconId, + iconCodePoint: args.iconCodePoint, + iconFontFamily: args.iconFontFamily, + iconFontPackage: args.iconFontPackage, + color: args.color, + customColorValue: args.customColorValue, + createdAt: now, + updatedAt: now, + }); + + return { ok: true } as const; + }, +}); + +export const update = mutation({ + args: { + ...cloudProtocolArgs, + folderPublicId: v.string(), + name: v.optional(v.string()), + iconId: v.optional(v.number()), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + clearIconFontFamily: v.optional(v.boolean()), + clearIconFontPackage: v.optional(v.boolean()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + clearCustomColorValue: v.optional(v.boolean()), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); + + if (role !== "owner") { + throw forbiddenError(); + } + + const patch: { + name?: string; + iconId?: number; + iconCodePoint?: number; + iconFontFamily?: string; + iconFontPackage?: string; + color?: string; + customColorValue?: number; + updatedAt: number; + } = { + updatedAt: Date.now(), + }; + + if (args.name !== undefined) { + patch.name = args.name; + } + if (args.iconId !== undefined) { + patch.iconId = args.iconId; + } + if (args.iconCodePoint !== undefined) { + patch.iconCodePoint = args.iconCodePoint; + } + if (args.clearIconFontFamily === true) { + patch.iconFontFamily = undefined; + } else if (args.iconFontFamily !== undefined) { + patch.iconFontFamily = args.iconFontFamily; + } + if (args.clearIconFontPackage === true) { + patch.iconFontPackage = undefined; + } else if (args.iconFontPackage !== undefined) { + patch.iconFontPackage = args.iconFontPackage; + } + if (args.color !== undefined) { + patch.color = args.color; + } + if (args.clearCustomColorValue === true) { + patch.customColorValue = undefined; + } else if (args.customColorValue !== undefined) { + patch.customColorValue = args.customColorValue; + } + + await ctx.db.patch(folder._id, patch); + return { ok: true } as const; + }, +}); + +export const listTree = query({ + args: { + scope: folderScopeValidator, + }, + returns: v.array(folderSummaryValidator), + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const scope = args.scope ?? "all"; + const accessible = await listAccessibleFoldersForScope( + ctx, + user._id, + scope, + ); + const folderLookup = new Map( + accessible.map(({ folder }) => [folder._id, folder]), + ); + const summaries = await summariseFolderTrees( + ctx, + accessible.map(({ folder }) => folder), + ); + + return accessible + .sort((a, b) => a.folder.createdAt - b.folder.createdAt) + .map(({ folder, role }) => ({ + publicId: folder.publicId, + name: folder.name, + iconId: folder.iconId ?? null, + iconCodePoint: folder.iconCodePoint ?? null, + iconFontFamily: folder.iconFontFamily ?? null, + iconFontPackage: folder.iconFontPackage ?? null, + color: folder.color ?? null, + customColorValue: folder.customColorValue ?? null, + parentFolderPublicId: + folder.parentFolderId === undefined + ? null + : (folderLookup.get(folder.parentFolderId)?.publicId ?? null), + createdAt: folder.createdAt, + updatedAt: folder.updatedAt, + role, + ...summaries.get(folder._id)!, + })); + }, +}); + +type FolderTreeSummary = { + strategyCount: number; + mapPeeks: string[]; + agentTypes: string[]; +}; + +/// One pass over the strategies of every listed folder, then counts roll up +/// from each folder into its ancestors so a parent summarises its whole +/// subtree. Folders outside the list (not accessible) contribute nothing. +async function summariseFolderTrees( + ctx: AnyCtx, + folders: Doc<"folders">[], +): Promise, FolderTreeSummary>> { + const listed = new Set(folders.map((folder) => folder._id)); + const strategyCounts = new Map, number>(); + const mapCounts = new Map, Map>(); + const agentCounts = new Map, Map>(); + for (const folder of folders) { + strategyCounts.set(folder._id, 0); + mapCounts.set(folder._id, new Map()); + agentCounts.set(folder._id, new Map()); + } + const bump = (counts: Map, key: string) => + counts.set(key, (counts.get(key) ?? 0) + 1); + + for (const folder of folders) { + const strategies = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + const agentTypesSeen = new Map(); + for (const strategy of strategies) { + bump(mapCounts.get(folder._id)!, strategy.mapData); + for (const type of await readStrategyAgentTypes(ctx, strategy._id)) { + bump(agentTypesSeen, type); + } + } + strategyCounts.set(folder._id, strategies.length); + agentCounts.set(folder._id, agentTypesSeen); + } + + // Roll every folder's own counts up through its listed ancestors. + const merged = new Map, FolderTreeSummary>(); + const totals = new Map< + Id<"folders">, + { strategies: number; maps: Map; agents: Map } + >(); + for (const folder of folders) { + totals.set(folder._id, { strategies: 0, maps: new Map(), agents: new Map() }); + } + const mergeCounts = (into: Map, from: Map) => { + for (const [key, count] of from) into.set(key, (into.get(key) ?? 0) + count); + }; + for (const folder of folders) { + let current: Doc<"folders"> | undefined = folder; + const visited = new Set>(); + while (current !== undefined && !visited.has(current._id)) { + visited.add(current._id); + const total = totals.get(current._id)!; + total.strategies += strategyCounts.get(folder._id)!; + mergeCounts(total.maps, mapCounts.get(folder._id)!); + mergeCounts(total.agents, agentCounts.get(folder._id)!); + const parentId: Id<"folders"> | undefined = current.parentFolderId; + current = + parentId !== undefined && listed.has(parentId) + ? folders.find((candidate) => candidate._id === parentId) + : undefined; + } + } + const ranked = (counts: Map) => + [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([key]) => key); + for (const folder of folders) { + const total = totals.get(folder._id)!; + merged.set(folder._id, { + strategyCount: total.strategies, + mapPeeks: ranked(total.maps).slice(0, 2), + agentTypes: ranked(total.agents), + }); + } + return merged; +} + +export const move = mutation({ + args: { + ...cloudProtocolArgs, + folderPublicId: v.string(), + parentFolderPublicId: v.optional(v.string()), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); + + if (role !== "owner") { + throw forbiddenError(); + } + + let parentFolderId: Id<"folders"> | undefined; + if (args.parentFolderPublicId !== undefined) { + const parent = await getFolderByPublicId(ctx, args.parentFolderPublicId); + const parentAccess = await assertFolderRole(ctx, parent, "owner"); + if (parentAccess.role !== "owner" || parent.ownerId !== folder.ownerId) { + throw forbiddenError(); + } + await assertFolderMoveIsAcyclic(ctx, folder._id, parent); + parentFolderId = parent._id; + } + + await ctx.db.patch(folder._id, { + parentFolderId, + updatedAt: Date.now(), + }); + + return { ok: true } as const; + }, +}); + +const deleteFolder = mutation({ + args: { + ...cloudProtocolArgs, + folderPublicId: v.string(), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + const { role } = await assertFolderRole(ctx, folder, "owner"); + + if (role !== "owner") { + throw forbiddenError(); + } + + const children = await ctx.db + .query("folders") + .withIndex("by_parentFolderId", (q) => q.eq("parentFolderId", folder._id)) + .collect(); + if (children.length > 0) { + throw invalidOpError("Folder has children"); + } + + const strategies = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + if (strategies.length > 0) { + throw invalidOpError("Folder has strategies"); + } + + const collaborators = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + for (const collaborator of collaborators) { + await ctx.db.delete(collaborator._id); + } + + const links = await ctx.db + .query("shareLinks") + .withIndex("by_folderId", (q) => q.eq("folderId", folder._id)) + .collect(); + for (const link of links) { + await ctx.db.delete(link._id); + } + + await ctx.db.delete(folder._id); + return { ok: true } as const; + }, +}); + +export { deleteFolder as delete }; diff --git a/convex/function_spec.json b/convex/function_spec.json new file mode 100644 index 00000000..1a8a6711 --- /dev/null +++ b/convex/function_spec.json @@ -0,0 +1,186884 @@ +{ + "functions": [ + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "elements.js:listForPage", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "elementType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "agent" + }, + { + "type": "literal", + "value": "ability" + }, + { + "type": "literal", + "value": "drawing" + }, + { + "type": "literal", + "value": "text" + }, + { + "type": "literal", + "value": "image" + }, + { + "type": "literal", + "value": "utility" + } + ] + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "elements.js:listForStrategy", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "elementType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "agent" + }, + { + "type": "literal", + "value": "ability" + }, + { + "type": "literal", + "value": "drawing" + }, + { + "type": "literal", + "value": "text" + }, + { + "type": "literal", + "value": "image" + }, + { + "type": "literal", + "value": "utility" + } + ] + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "color": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "customColorValue": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "iconCodePoint": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "iconFontFamily": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "iconFontPackage": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "iconId": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "parentFolderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "folders.js:create", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "folders.js:delete", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "scope": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owned" + }, + { + "type": "literal", + "value": "shared" + }, + { + "type": "literal", + "value": "all" + } + ] + }, + "optional": true + } + } + }, + "functionType": "Query", + "identifier": "folders.js:listTree", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "agentTypes": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "color": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "customColorValue": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "iconCodePoint": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "iconFontFamily": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "iconFontPackage": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "iconId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mapPeeks": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "parentFolderPublicId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "strategyCount": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "parentFolderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "folders.js:move", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clearCustomColorValue": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clearIconFontFamily": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clearIconFontPackage": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "color": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "customColorValue": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "iconCodePoint": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "iconFontFamily": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "iconFontPackage": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "iconId": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "folders.js:update", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Query", + "identifier": "health.js:ping", + "returns": { + "type": "literal", + "value": "ok" + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:claimDeletedImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "height": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "mimeType": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "storageId": { + "fieldType": { + "tableName": "_storage", + "type": "id" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:completeLegacyUpload", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "byteSize": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "etag": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "height": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "mimeType": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "objectKey": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "provider": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "convex" + }, + { + "type": "literal", + "value": "r2" + } + ] + }, + "optional": true + }, + "storageId": { + "fieldType": { + "tableName": "_storage", + "type": "id" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "width": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Action", + "identifier": "images.js:completeUpload", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "literal", + "value": "convex" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "literal", + "value": "r2" + }, + "optional": false + }, + "url": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "byteSize": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "mimeType": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "objectKey": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadAttemptPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:createR2UploadIntent", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Action", + "identifier": "images.js:deleteAssetRef", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "assetId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + }, + "r2ObjectDeleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:finalizeDeletedImageAsset", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "byteSize": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "mimeType": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Action", + "identifier": "images.js:generateUploadUrl", + "returns": { + "type": "object", + "value": { + "expiresAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "maxBytes": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "objectKey": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "literal", + "value": "r2" + }, + "optional": false + }, + "requiredHeaders": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "string" + }, + "optional": false + } + }, + "optional": false + }, + "uploadId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadUrl": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:getAssetDeletionTarget", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "shareToken": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:getAssetUrl", + "returns": { + "type": "object", + "value": { + "url": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "assetId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:getDeletedImageAssetTarget", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadAttemptPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:getR2UploadIntentForCompletion", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:listForStrategy", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "byteSize": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "legacyStoragePath": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mimeType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "convex" + }, + { + "type": "literal", + "value": "r2" + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadedAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "uploadStatus": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "pending" + }, + { + "type": "literal", + "value": "active" + }, + { + "type": "literal", + "value": "failed" + }, + { + "type": "literal", + "value": "deleted" + } + ] + }, + "optional": false + }, + "url": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:listPotentiallyStale", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicIds": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "images.js:listReferencedAssetIds", + "returns": { + "type": "union", + "value": [ + { + "type": "array", + "value": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "assetIds": { + "fieldType": { + "type": "array", + "value": { + "tableName": "imageAssets", + "type": "id" + } + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markDeletedAssetRefsForStrategy", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicIds": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "pageId": { + "fieldType": { + "tableName": "pages", + "type": "id" + }, + "optional": false + }, + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markDeletedPageImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markDeletedStrategyImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicIds": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markPurgedTombstoneImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "byteSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "etag": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "mimeType": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markR2UploadActive", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "reason": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "uploadId": { + "fieldType": { + "tableName": "imageAssets", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markR2UploadFailed", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "staleBefore": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:markStaleImageUploadsDeleted", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "images.js:processAssetReclaimCandidates", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "assetIds": { + "fieldType": { + "type": "array", + "value": { + "tableName": "imageAssets", + "type": "id" + } + }, + "optional": false + }, + "retryAfterMs": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:releaseImageAssetDeletionClaims", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "delayMs": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "images.js:scheduleDeletedImageAssetSweep", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "limit": { + "fieldType": { + "type": "number" + }, + "optional": true + } + } + }, + "functionType": "Action", + "identifier": "images.js:sweepDeletedImageAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expiresAt": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "invites.js:create", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Query", + "identifier": "invites.js:get", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expiresAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "hasAccessAlready": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "inviteRole": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "revoked": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expiresAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "redeemed": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "revokedAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + } + }, + { + "type": "null" + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "invites.js:redeem", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "invites.js:revoke", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "lineups.js:listForPage", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "lineups.js:listForStrategy", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "paginationOpts": { + "fieldType": { + "type": "object", + "value": { + "cursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "endCursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": true + }, + "id": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumBytesRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumRowsRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "numItems": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "table": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "elements" + }, + { + "type": "literal", + "value": "lineups" + } + ] + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "maintenance.js:backfillAssetReferences", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:backfillStrategyAgentSummaries", + "returns": { + "type": "number" + }, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "paginationOpts": { + "fieldType": { + "type": "object", + "value": { + "cursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "endCursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": true + }, + "id": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumBytesRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumRowsRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "numItems": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "table": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "elements" + }, + { + "type": "literal", + "value": "lineups" + } + ] + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "maintenance.js:checkAssetReferences", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "paginationOpts": { + "fieldType": { + "type": "object", + "value": { + "cursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "endCursor": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": true + }, + "id": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumBytesRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "maximumRowsRead": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "numItems": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "table": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "elements" + }, + { + "type": "literal", + "value": "lineups" + } + ] + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "maintenance.js:findShownUnavailableAssets", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "pageId": { + "fieldType": { + "tableName": "pages", + "type": "id" + }, + "optional": false + }, + "strategyId": { + "fieldType": { + "tableName": "strategies", + "type": "id" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "maintenance.js:purgeDeletedPageOrphans", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:purgeOldOperationEvents", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:purgeOldTombstones", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Mutation", + "identifier": "maintenance.js:purgeTrashedPages", + "returns": null, + "visibility": { + "kind": "internal" + } + }, + { + "args": { + "type": "object", + "value": { + "accountSubject": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "checkLineupEndDeletes": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "checkLineupLinkEnds": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "checkTrashedPageDeletes": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clientId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "ops": { + "fieldType": { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "expectedStrategyRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "clearThemeOverridePalette": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clearThemeProfileId": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "themeOverridePalette": { + "fieldType": { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "optional": true + }, + "themeProfileId": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "strategy.patch" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedStrategyRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "settings": { + "fieldType": { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "optional": true + } + } + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "page.add" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedPageRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "settings": { + "fieldType": { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "optional": true + } + } + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "page.patch" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedStrategyRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "page.delete" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedStrategyRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "page.reorder" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedPageContentRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "settings": { + "fieldType": { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "pageContent.patch" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "elementPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "expectedElementRevision": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "element.add" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "elementPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "expectedElementRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": true + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "type": { + "fieldType": { + "type": "literal", + "value": "element.patch" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "elementPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "expectedElementRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "element.delete" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "elementPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "expectedElementRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "element.reorder" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedLineupRevision": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "lineupPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupOrigin" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupLanding" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupLink" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "lineup.add" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedLineupRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "lineupPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupOrigin" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupLanding" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineupLink" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": true + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "type": { + "fieldType": { + "type": "literal", + "value": "lineup.patch" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedLineupRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "lineupPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "lineup.delete" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "expectedLineupRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "lineupPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "lineup.reorder" + }, + "optional": false + } + } + } + ] + } + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "ops.js:applyBatch", + "returns": { + "type": "object", + "value": { + "results": { + "fieldType": { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "appliedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "status": { + "fieldType": { + "type": "literal", + "value": "applied" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "currentRevision": { + "fieldType": { + "type": "number" + }, + "optional": true + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "status": { + "fieldType": { + "type": "literal", + "value": "noop" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "current": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "strategy" + }, + "optional": false + }, + "value": { + "fieldType": { + "type": "object", + "value": { + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "page" + }, + "optional": false + }, + "value": { + "fieldType": { + "type": "object", + "value": { + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "pageContent" + }, + "optional": false + }, + "value": { + "fieldType": { + "type": "object", + "value": { + "settings": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "element" + }, + "optional": false + }, + "value": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "type": { + "fieldType": { + "type": "literal", + "value": "lineup" + }, + "optional": false + }, + "value": { + "fieldType": { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + } + } + } + ] + }, + "optional": true + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "reason": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "already_exists" + }, + { + "type": "literal", + "value": "deleted" + }, + { + "type": "literal", + "value": "element_strategy_mismatch" + }, + { + "type": "literal", + "value": "lineup_strategy_mismatch" + }, + { + "type": "literal", + "value": "missing_expected_revision" + }, + { + "type": "literal", + "value": "not_found" + }, + { + "type": "literal", + "value": "page_strategy_mismatch" + }, + { + "type": "literal", + "value": "revision_mismatch" + } + ] + }, + "optional": false + }, + "status": { + "fieldType": { + "type": "literal", + "value": "rejected" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "code": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "message": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "opId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "rawCode": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "status": { + "fieldType": { + "type": "literal", + "value": "failed" + }, + "optional": false + } + } + } + ] + } + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "shareToken": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "page.js:getSnapshot", + "returns": { + "type": "object", + "value": { + "assets": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "byteSize": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "legacyStoragePath": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mimeType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "convex" + }, + { + "type": "literal", + "value": "r2" + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadedAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "uploadStatus": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "pending" + }, + { + "type": "literal", + "value": "active" + }, + { + "type": "literal", + "value": "failed" + }, + { + "type": "literal", + "value": "deleted" + } + ] + }, + "optional": false + }, + "url": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + } + }, + "optional": false + }, + "content": { + "fieldType": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "settings": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "elements": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "elementType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "agent" + }, + { + "type": "literal", + "value": "ability" + }, + { + "type": "literal", + "value": "drawing" + }, + { + "type": "literal", + "value": "text" + }, + { + "type": "literal", + "value": "image" + }, + { + "type": "literal", + "value": "utility" + } + ] + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + }, + "lineups": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + }, + "page": { + "fieldType": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "settings": { + "fieldType": { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "optional": true + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "pages.js:add", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "pages.js:delete", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "pages.js:listForStrategy", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "pages.js:listTrashed", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "deletedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deletedByName": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "deletedByYou": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "restorableUntil": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "pages.js:rename", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "orderedPagePublicIds": { + "fieldType": { + "type": "array", + "value": { + "type": "string" + } + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "pages.js:reorder", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "pages.js:restore", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "presence.js:issueRoomPass", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "expiresAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "pass": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "url": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "viewer" + }, + { + "type": "literal", + "value": "editor" + } + ] + }, + "optional": false + }, + "targetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "strategy" + }, + { + "type": "literal", + "value": "folder" + } + ] + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "shares.js:create", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "targetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "strategy" + }, + { + "type": "literal", + "value": "folder" + } + ] + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "shares.js:list", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "revokedAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "viewer" + }, + { + "type": "literal", + "value": "editor" + } + ] + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "shares.js:redeem", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "alreadyHadAccess": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "folderPublicId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "literal", + "value": "strategy" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "alreadyHadAccess": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "literal", + "value": "folder" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "shares.js:resolve", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "viewer" + }, + { + "type": "literal", + "value": "editor" + } + ] + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "literal", + "value": "strategy" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "viewer" + }, + { + "type": "literal", + "value": "editor" + } + ] + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "literal", + "value": "folder" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "targetPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "targetType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "strategy" + }, + { + "type": "literal", + "value": "folder" + } + ] + }, + "optional": false + }, + "token": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "shares.js:revoke", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "optional": true + }, + "themeProfileId": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:create", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "initialPageIsAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "initialPageIsAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "initialPageName": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "initialPagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "initialPageSettings": { + "fieldType": { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + "optional": true + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "optional": true + }, + "themeProfileId": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:createWithInitialPage", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:delete", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "sourceStrategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:duplicate", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "strategies.js:getHeader", + "returns": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owned" + }, + { + "type": "literal", + "value": "shared" + }, + { + "type": "literal", + "value": "all" + } + ] + }, + "optional": true + } + } + }, + "functionType": "Query", + "identifier": "strategies.js:listForFolder", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "attackLabel": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "Unknown" + }, + { + "type": "literal", + "value": "Mixed" + }, + { + "type": "literal", + "value": "Attack" + }, + { + "type": "literal", + "value": "Defend" + } + ] + }, + "optional": false + }, + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Query", + "identifier": "strategies.js:listSharedWithMe", + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "attackLabel": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "Unknown" + }, + { + "type": "literal", + "value": "Mixed" + }, + { + "type": "literal", + "value": "Attack" + }, + { + "type": "literal", + "value": "Defend" + } + ] + }, + "optional": false + }, + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "folderPublicId": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:move", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clearThemeOverridePalette": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clearThemeProfileId": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "expectedRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "optional": true + }, + "themeProfileId": { + "fieldType": { + "type": "string" + }, + "optional": true + } + } + }, + "functionType": "Mutation", + "identifier": "strategies.js:update", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "reused": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "acceptsTrashedPagesLeftOut": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "shareToken": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "strategy.js:getFullSnapshot", + "returns": { + "type": "object", + "value": { + "assets": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "byteSize": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "fileExtension": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "height": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "legacyStoragePath": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "mimeType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "provider": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "convex" + }, + { + "type": "literal", + "value": "r2" + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "uploadedAt": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "uploadStatus": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "pending" + }, + { + "type": "literal", + "value": "active" + }, + { + "type": "literal", + "value": "failed" + }, + { + "type": "literal", + "value": "deleted" + } + ] + }, + "optional": false + }, + "url": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "width": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "optional": false + } + } + } + }, + "optional": false + }, + "elements": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "elementType": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "agent" + }, + { + "type": "literal", + "value": "ability" + }, + { + "type": "literal", + "value": "drawing" + }, + { + "type": "literal", + "value": "text" + }, + { + "type": "literal", + "value": "image" + }, + { + "type": "literal", + "value": "utility" + } + ] + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "agent" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "ability" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "drawing" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "text" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "image" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "utility" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + ] + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + }, + "header": { + "fieldType": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "lineups": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "deleted": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "pagePublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "payload": { + "fieldType": { + "type": "object", + "value": { + "data": { + "fieldType": { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + { + "type": "array", + "value": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + }, + { + "keys": { + "type": "string" + }, + "type": "record", + "values": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + } + ] + }, + "optional": false + } + }, + "optional": false + }, + "kind": { + "fieldType": { + "type": "literal", + "value": "lineups" + }, + "optional": false + }, + "payloadVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + }, + "pages": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "contentCreatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "contentRevision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "contentUpdatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "settings": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "abilitySize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "agentSize": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "useNeutralTeamColors": { + "fieldType": { + "type": "boolean" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "shareToken": { + "fieldType": { + "type": "string" + }, + "optional": true + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + "functionType": "Query", + "identifier": "strategy.js:getShell", + "returns": { + "type": "object", + "value": { + "header": { + "fieldType": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "mapData": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "role": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "literal", + "value": "owner" + }, + { + "type": "literal", + "value": "editor" + }, + { + "type": "literal", + "value": "viewer" + } + ] + }, + "optional": false + }, + "themeOverridePalette": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "base": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "detail": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "highlight": { + "fieldType": { + "type": "string" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "themeProfileId": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "optional": false + }, + "pages": { + "fieldType": { + "type": "array", + "value": { + "type": "object", + "value": { + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "isAttack": { + "fieldType": { + "type": "boolean" + }, + "optional": false + }, + "isAutoNamed": { + "fieldType": { + "type": "boolean" + }, + "optional": true + }, + "name": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "publicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "revision": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "sortIndex": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "strategyPublicId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + } + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": { + "clientProtocolVersion": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + "functionType": "Mutation", + "identifier": "users.js:ensureCurrentUser", + "returns": { + "type": "object", + "value": { + "ok": { + "fieldType": { + "type": "literal", + "value": true + }, + "optional": false + } + } + }, + "visibility": { + "kind": "public" + } + }, + { + "args": { + "type": "object", + "value": {} + }, + "functionType": "Query", + "identifier": "users.js:me", + "returns": { + "type": "union", + "value": [ + { + "type": "object", + "value": { + "avatarUrl": { + "fieldType": { + "type": "union", + "value": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "optional": false + }, + "createdAt": { + "fieldType": { + "type": "number" + }, + "optional": false + }, + "displayName": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "externalId": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "id": { + "fieldType": { + "type": "string" + }, + "optional": false + }, + "updatedAt": { + "fieldType": { + "type": "number" + }, + "optional": false + } + } + }, + { + "type": "null" + } + ] + }, + "visibility": { + "kind": "public" + } + } + ] +} diff --git a/convex/health.ts b/convex/health.ts new file mode 100644 index 00000000..3a3dd837 --- /dev/null +++ b/convex/health.ts @@ -0,0 +1,20 @@ +// convex/health.ts +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertSupportedCloudProtocol } from "./lib/cloudProtocol"; + +export const ping = query({ + args: { + // Given, the ping also answers whether this protocol is accepted: a + // client the server once refused asks it to learn when it may sync + // again. The connection health check sends nothing. + clientProtocolVersion: v.optional(v.number()), + }, + returns: v.literal("ok"), + handler: async (_ctx, args) => { + if (args.clientProtocolVersion !== undefined) { + assertSupportedCloudProtocol(args.clientProtocolVersion); + } + return "ok" as const; + }, +}); diff --git a/convex/imageAssetLifecycle.test.ts b/convex/imageAssetLifecycle.test.ts new file mode 100644 index 00000000..d49f7c57 --- /dev/null +++ b/convex/imageAssetLifecycle.test.ts @@ -0,0 +1,761 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import cronDefinitions from "./crons"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import { PAGE_TRASH_RETENTION_MS } from "./lib/entities"; +import schema from "./schema"; +import { + insertElement, + insertLineup, + lineupsPayload, +} from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const deletePage = makeFunctionReference<"mutation">("pages:delete"); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); +const purgeTrashedPages = makeFunctionReference<"mutation">( + "maintenance:purgeTrashedPages", +); +const markStaleImageUploadsDeleted = makeFunctionReference<"mutation">( + "images:markStaleImageUploadsDeleted", +); +const sweepDeletedImageAssets = makeFunctionReference<"action">( + "images:sweepDeletedImageAssets", +); +const completeUpload = makeFunctionReference<"action">("images:completeUpload"); +const generateUploadUrl = makeFunctionReference<"action">( + "images:generateUploadUrl", +); +const getAssetUrl = makeFunctionReference<"query">("images:getAssetUrl"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +const strategyPublicId = "asset-lifecycle-strategy"; +const pageA = "asset-page-a"; +const pageB = "asset-page-b"; + +function identity() { + return { + issuer: "https://asset-lifecycle.test", + subject: "owner", + tokenIdentifier: "asset-lifecycle|owner", + name: "Asset Owner", + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + // A deployment whose reference backfill has run, as every one will be. + await t.run(markAssetReferencesReady); + const owner = t.withIdentity(identity()); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + return { t, owner }; +} + +async function seedStrategy(owner: Harness): Promise { + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "Asset lifecycle", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); +} + +async function getStrategyAndPages(t: RootHarness) { + return await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + if (strategy === null) { + throw new Error("Missing Strategy test row"); + } + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + return { strategy, pages }; + }); +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +function mockR2Deletes(statuses: number[] = [204]) { + let callIndex = 0; + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => { + const status = statuses[Math.min(callIndex, statuses.length - 1)]!; + callIndex += 1; + return new Response(null, { status }); + }, + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +async function allAssets(t: RootHarness) { + return await t.run( + async (ctx) => await ctx.db.query("imageAssets").collect(), + ); +} + +beforeAll(() => { + process.env.R2_ACCOUNT_ID = "asset-lifecycle-account"; + process.env.R2_BUCKET = "asset-lifecycle-bucket"; + process.env.R2_ACCESS_KEY_ID = "asset-lifecycle-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "asset-lifecycle-secret"; + process.env.R2_PUBLIC_BASE_URL = "https://assets.asset-lifecycle.test"; + process.env.R2_S3_ENDPOINT = "https://asset-lifecycle.r2.test"; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("image asset lifecycle", () => { + test("stale pending uploads require a fresh intent even when referenced", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy, pages } = await getStrategyAndPages(t); + const staleAt = Date.now() - 48 * 60 * 60 * 1000; + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "offline-image", strategyId: strategy._id, provider: "r2", + objectKey: "offline/image.png", uploadAttemptPublicId: "offline-attempt", + uploadStatus: "pending", fileExtension: ".png", mimeType: "image/png", + createdAt: staleAt, updatedAt: staleAt, + }); + await insertElement(ctx, { + publicId: "offline-image", strategyId: strategy._id, pageId: pages[0]!._id, + elementType: "image", payloadKind: "image", payloadVersion: 1, payload: imagePayload("offline-image"), + sortIndex: 0, revision: 1, deleted: false, createdAt: staleAt, updatedAt: staleAt, + }); + }); + await t.mutation(markStaleImageUploadsDeleted, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalled(); + await expect(owner.action(completeUpload, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, assetPublicId: "offline-image", provider: "r2", + uploadId: "offline-attempt", objectKey: "offline/image.png", + })).rejects.toThrow(/Upload intent not found/); + }); + + test("a deleted page keeps its images in the trash; its purge removes only assets unreferenced by remaining Pages and Lineups", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "Page 2", + sortIndex: 1, + isAttack: false, + }); + const { strategy, pages } = await getStrategyAndPages(t); + const pageAId = pages.find((page) => page.publicId === pageA)?._id; + const pageBId = pages.find((page) => page.publicId === pageB)?._id; + if (pageAId === undefined || pageBId === undefined) { + throw new Error("Missing Page test rows"); + } + + await t.run(async (ctx) => { + const now = Date.now(); + for (const [publicId, objectKey] of [ + ["page-only", "pages/page-only.png"], + ["tombstoned-page-only", "pages/tombstoned-page-only.png"], + ["still-used", "pages/still-used.png"], + ["still-used-by-element", "pages/still-used-by-element.png"], + ["still-used-by-link", "pages/still-used-by-link.png"], + ] as const) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy._id, + objectKey, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + } + for (const [publicId, assetPublicId] of [ + ["page-only-element", "page-only"], + ["tombstoned-element", "tombstoned-page-only"], + ["shared-element", "still-used"], + ["shared-page-element", "still-used-by-element"], + ["shared-link-element", "still-used-by-link"], + ] as const) { + await insertElement(ctx, { + publicId, + strategyId: strategy._id, + pageId: pageAId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload(assetPublicId), + sortIndex: 0, + revision: 1, + deleted: publicId === "tombstoned-element", + createdAt: now, + updatedAt: now, + }); + } + // One lineup group whose two lineups each show an image. + await insertLineup(ctx, { + publicId: "remaining-lineup", + strategyId: strategy._id, + pageId: pageBId, + payloadKind: "lineups", + payloadVersion: 1, + payload: lineupsPayload("remaining-lineup", { + origins: [{ id: "origin" }], + landings: [{ id: "landing" }, { id: "other-landing" }], + links: [ + { + id: "remaining-lineup", + originId: "origin", + landingId: "landing", + images: [{ id: "still-used" }], + }, + { + id: "remaining-link", + originId: "origin", + landingId: "other-landing", + images: [{ id: "still-used-by-link", fileExtension: ".png" }], + }, + ], + }), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await insertElement(ctx, { + publicId: "remaining-image-element", + strategyId: strategy._id, + pageId: pageBId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload("still-used-by-element"), + sortIndex: 1, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + + await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + expectedRevision: 1, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + // In the trash, the page can still be restored with its images. + expect( + (await allAssets(t)).map((asset) => asset.uploadStatus), + ).toEqual(["active", "active", "active", "active", "active"]); + expect(fetchMock).not.toHaveBeenCalled(); + + vi.setSystemTime(Date.now() + PAGE_TRASH_RETENTION_MS + 1); + await t.mutation(purgeTrashedPages, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const assets = await allAssets(t); + expect(assets).toMatchObject([ + { publicId: "still-used", uploadStatus: "active" }, + { publicId: "still-used-by-element", uploadStatus: "active" }, + { publicId: "still-used-by-link", uploadStatus: "active" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ method: "DELETE" }); + expect(fetchMock.mock.calls.map((call) => String(call[0])).sort()).toEqual( + expect.arrayContaining([ + expect.stringContaining("page-only.png"), + expect.stringContaining("tombstoned-page-only.png"), + ]), + ); + }); + + test("Strategy deletion reclaims exact-owned R2 and Convex assets but preserves ambiguous shared legacy rows", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const { uniqueStorageId, sharedStorageId } = await t.run(async (ctx) => ({ + uniqueStorageId: await ctx.storage.store(new Blob(["unique"])), + sharedStorageId: await ctx.storage.store(new Blob(["shared"])), + })); + + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "owned-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "strategies/owned/delete.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-shared-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "legacy/shared.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-shared-r2", + provider: "r2", + objectKey: "legacy/shared.png", + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-deleted-r2", + provider: "r2", + objectKey: "legacy/ambiguous-deleted.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-convex", + provider: "convex", + strategyId: strategy._id, + storageId: uniqueStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "owned-shared-convex", + provider: "convex", + strategyId: strategy._id, + storageId: sharedStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: "legacy-shared-convex", + provider: "convex", + storageId: sharedStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + }); + + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toMatchObject([ + { publicId: "legacy-shared-r2", objectKey: "legacy/shared.png" }, + { + publicId: "legacy-deleted-r2", + objectKey: "legacy/ambiguous-deleted.png", + }, + { publicId: "legacy-shared-convex", storageId: sharedStorageId }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("delete.png"); + await expect( + t.run(async (ctx) => (await ctx.storage.get(uniqueStorageId)) === null), + ).resolves.toBe(true); + await expect( + t.run(async (ctx) => (await ctx.storage.get(sharedStorageId)) !== null), + ).resolves.toBe(true); + }); + + test("R2 failure keeps the tombstone target for an idempotent retry", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes([500, 404]); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const assetId = await t.run(async (ctx) => { + const now = Date.now(); + return await ctx.db.insert("imageAssets", { + publicId: "retry-r2", + provider: "r2", + strategyId: strategy._id, + objectKey: "retry/keep-target.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 1 }), + ).resolves.toMatchObject({ deleted: 0, failed: 1 }); + await expect( + t.run(async (ctx) => await ctx.db.get(assetId)), + ).resolves.toMatchObject({ + uploadStatus: "deleted", + objectKey: "retry/keep-target.png", + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 1 }), + ).resolves.toMatchObject({ deleted: 1, failed: 0 }); + await expect( + t.run(async (ctx) => await ctx.db.get(assetId)), + ).resolves.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(2); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("the last deleted row sharing an R2 key removes the object", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (const publicId of ["duplicate-a", "duplicate-b"]) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy._id, + objectKey: "duplicates/shared-deleted.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + } + }); + + await expect( + t.action(sweepDeletedImageAssets, { limit: 2 }), + ).resolves.toMatchObject({ deleted: 2, failed: 0 }); + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain( + "shared-deleted.png", + ); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("overlapping cleanup actions claim one R2 tombstone once", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "single-claim", + provider: "r2", + strategyId: strategy._id, + objectKey: "claims/single.png", + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + }); + + const results = (await Promise.all([ + t.action(sweepDeletedImageAssets, { limit: 1 }), + t.action(sweepDeletedImageAssets, { limit: 1 }), + ])) as Array<{ deleted: number; failed: number }>; + + expect(results.reduce((total, result) => total + result.deleted, 0)).toBe( + 1, + ); + expect(results.reduce((total, result) => total + result.failed, 0)).toBe(0); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await allAssets(t)).toEqual([]); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("the hourly path releases a stranded claim and completes its deletion", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const now = Date.now(); + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "stranded-claim", + provider: "r2", + strategyId: strategy._id, + objectKey: "claims/stranded.png", + uploadStatus: "deleted", + deletedAt: now - 60 * 60 * 1000, + cleanupClaimedAt: now - 16 * 60 * 1000, + createdAt: now - 60 * 60 * 1000, + updatedAt: now - 60 * 60 * 1000, + }); + }); + + await expect( + t.mutation(markStaleImageUploadsDeleted, {}), + ).resolves.toMatchObject({ deleted: 0, released: 1 }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("an upload deleted while its URL still works keeps its row until the URL dies", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const upload = (await owner.action(generateUploadUrl, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + assetPublicId: "in-flight", + mimeType: "image/png", + fileExtension: "png", + })) as { objectKey: string; expiresAt: number }; + expect(await allAssets(t)).toMatchObject([ + { uploadStatus: "pending", uploadUrlExpiresAt: upload.expiresAt }, + ]); + + // The strategy goes while the client's PUT may still be sending bytes. + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(fetchMock).not.toHaveBeenCalled(); + expect(await allAssets(t)).toMatchObject([ + { uploadStatus: "deleted", objectKey: upload.objectKey }, + ]); + + // Well after expiry, a PUT that started in time may still be sending + // over a slow connection. + vi.setSystemTime(upload.expiresAt + 23 * 60 * 60 * 1000); + await expect( + t.action(sweepDeletedImageAssets, {}), + ).resolves.toMatchObject({ deleted: 0, failed: 0 }); + + vi.setSystemTime(upload.expiresAt + 24 * 60 * 60 * 1000); + await expect( + t.action(sweepDeletedImageAssets, {}), + ).resolves.toMatchObject({ deleted: 1, failed: 0 }); + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain(upload.objectKey); + await t.finishAllScheduledFunctions(vi.runAllTimers); + }); + + test("legacy reads survive while completion inserts an exact-owned replacement", async () => { + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy, pages } = await getStrategyAndPages(t); + const pageId = pages.find((page) => page.publicId === pageA)?._id; + if (pageId === undefined) { + throw new Error("Missing Page test row"); + } + const { legacyStorageId, replacementStorageId } = await t.run( + async (ctx) => ({ + legacyStorageId: await ctx.storage.store(new Blob(["legacy"])), + replacementStorageId: await ctx.storage.store( + new Blob(["replacement"]), + ), + }), + ); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "legacy-readable", + provider: "convex", + storageId: legacyStorageId, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + await insertElement(ctx, { + publicId: "legacy-image-element", + strategyId: strategy._id, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload("legacy-readable"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + + const legacyResult = (await owner.query(getAssetUrl, { + strategyPublicId, + assetPublicId: "legacy-readable", + })) as { url: string | null }; + expect(legacyResult.url).toMatch( + /^https:\/\/some-deployment\.convex\.cloud\//, + ); + await owner.action(completeUpload, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + assetPublicId: "legacy-readable", + provider: "convex", + storageId: replacementStorageId, + fileExtension: ".png", + mimeType: "image/png", + }); + + const assets = await allAssets(t); + expect( + assets.find((asset) => asset.strategyId === undefined), + ).toMatchObject({ + publicId: "legacy-readable", + storageId: legacyStorageId, + }); + expect( + assets.find((asset) => asset.strategyId === strategy._id), + ).toMatchObject({ + publicId: "legacy-readable", + storageId: replacementStorageId, + }); + const replacementResult = (await owner.query(getAssetUrl, { + strategyPublicId, + assetPublicId: "legacy-readable", + })) as { url: string | null }; + expect(replacementResult.url).toMatch( + /^https:\/\/some-deployment\.convex\.cloud\//, + ); + expect(replacementResult.url).not.toBe(legacyResult.url); + }); + + test("the cron path marks stale owned uploads without user auth and leaves ambiguous legacy rows", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + const staleAt = Date.now() - 48 * 60 * 60 * 1000; + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "stale-owned", + provider: "r2", + strategyId: strategy._id, + objectKey: "stale/owned.png", + uploadStatus: "pending", + createdAt: staleAt, + updatedAt: staleAt, + }); + await ctx.db.insert("imageAssets", { + publicId: "stale-legacy", + provider: "r2", + objectKey: "stale/legacy.png", + uploadStatus: "failed", + createdAt: staleAt, + updatedAt: staleAt, + }); + }); + + expect( + cronDefinitions.crons["mark-stale-image-uploads-deleted"], + ).toMatchObject({ + name: "images:markStaleImageUploadsDeleted", + schedule: { hours: 1, type: "interval" }, + }); + await expect( + t.mutation(markStaleImageUploadsDeleted, { + staleBefore: Date.now() - 24 * 60 * 60 * 1000, + }), + ).resolves.toMatchObject({ deleted: 1 }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toMatchObject([ + { publicId: "stale-legacy", uploadStatus: "failed" }, + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + test("Strategy cleanup continues through bounded database and external deletion batches", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedStrategy(owner); + const { strategy } = await getStrategyAndPages(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 126; index += 1) { + await ctx.db.insert("imageAssets", { + publicId: `bounded-${index}`, + provider: "r2", + strategyId: strategy._id, + objectKey: `bounded/${index}.png`, + uploadStatus: "active", + createdAt: now, + updatedAt: now, + }); + } + }); + + await owner.mutation(deleteStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await allAssets(t)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(126); + }, 30_000); +}); diff --git a/convex/images.ts b/convex/images.ts new file mode 100644 index 00000000..945b30ec --- /dev/null +++ b/convex/images.ts @@ -0,0 +1,1326 @@ +import { internal } from "./_generated/api"; +import type { Doc, Id } from "./_generated/dataModel"; +import { + addAssetReclaimCandidates, + assetReferencesReady, + collectLiveAssetIds, + isAssetReferenced, +} from "./lib/assetReferences"; +import { + getActiveAssetForStrategy, + inferFileExtension, + getViewerAssetForStrategy, + inferProvider, + inferUploadStatus, + isUploadPlaceholder, + serializeAssetForViewer, + staleUploadAgeMs, + type Provider, + type UploadStatus, +} from "./lib/imageAssets"; +import { + action, + internalAction, + internalMutation, + internalQuery, + query, + type MutationCtx, + type QueryCtx, +} from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyReadable, assertStrategyRole } from "./lib/auth"; +import { getStrategyByPublicId } from "./lib/entities"; +import { + createR2ObjectKey, + deleteR2Object, + expectedMimeTypeForExtension, + getR2Config, + getUploadUrlExpiresSeconds, + headR2Object, + normalizeImageExtension, + presignR2PutUrl, + publicR2UrlForObjectKey, + validateImageUploadMetadata, +} from "./lib/r2"; +import { + conflictError, + errorWithCode, + internalError, + invalidPayloadError, + notFoundError, +} from "./lib/errors"; +import { + imageAssetValidator, + imageProviderValidator, + okResultValidator, +} from "./lib/publicValidators"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { makeFunctionReference } from "convex/server"; + +type AnyCtx = MutationCtx | QueryCtx; + +type DeletionTarget = { + assetId: Id<"imageAssets">; + provider: Provider; + objectKey: string | null; + sharedTarget: boolean; +}; + +const maxDeletionBatch = 100; +const physicalDeletionBatch = 25; +// Each candidate costs a few small indexed reads, whatever the strategy's +// size, so a batch stays far below Convex's per-transaction limits. +const reclaimCandidateBatch = 25; +const staleDeletionClaimAgeMs = 15 * 60 * 1000; +// R2 checks a presigned URL when the PUT starts, so a PUT that started just +// before expiry can still be sending bytes after it, for as long as a slow +// connection takes. A day covers any real upload, and waiting costs little: +// a deleted element's image already stays for its 30-day tombstone. +const uploadInFlightGraceMs = 24 * 60 * 60 * 1000; +const deletionRetryDelayMs = 60 * 1000; + +export const markDeletedStrategyImageAssetsRef = + makeFunctionReference<"mutation">("images:markDeletedStrategyImageAssets"); +export const processAssetReclaimCandidatesRef = + makeFunctionReference<"mutation">("images:processAssetReclaimCandidates"); +const markDeletedPageImageAssetsRef = makeFunctionReference<"mutation">( + "images:markDeletedPageImageAssets", +); +const markPurgedTombstoneImageAssetsRef = makeFunctionReference<"mutation">( + "images:markPurgedTombstoneImageAssets", +); +export const markStaleImageUploadsDeletedRef = + makeFunctionReference<"mutation">("images:markStaleImageUploadsDeleted"); +export const sweepDeletedImageAssetsRef = makeFunctionReference<"action">( + "images:sweepDeletedImageAssets", +); +const claimDeletedImageAssetsRef = makeFunctionReference<"mutation">( + "images:claimDeletedImageAssets", +); +const getDeletedImageAssetTargetRef = makeFunctionReference<"query">( + "images:getDeletedImageAssetTarget", +); +const finalizeDeletedImageAssetRef = makeFunctionReference<"mutation">( + "images:finalizeDeletedImageAsset", +); +const scheduleDeletedImageAssetSweepRef = makeFunctionReference<"mutation">( + "images:scheduleDeletedImageAssetSweep", +); +const releaseImageAssetDeletionClaimsRef = makeFunctionReference<"mutation">( + "images:releaseImageAssetDeletionClaims", +); + +function createUploadAttemptPublicId(): string { + return crypto.randomUUID(); +} + +async function getDeletionCandidateForStrategy( + ctx: AnyCtx, + strategyId: Id<"strategies">, + assetPublicId: string, +): Promise | null> { + const strategyCandidates = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId", (q) => + q.eq("strategyId", strategyId).eq("publicId", assetPublicId), + ) + .order("desc") + .take(20); + const ownedCandidate = + strategyCandidates.find((asset) => inferUploadStatus(asset) !== "deleted") ?? + null; + return ownedCandidate; +} + +async function strategyReferencesAsset( + ctx: AnyCtx, + strategyId: Doc<"strategies">["_id"], + assetPublicId: string, +): Promise { + return await isAssetReferenced(ctx, strategyId, assetPublicId, { + includeTombstones: false, + }); +} + +async function markImageAssetDeleted( + ctx: MutationCtx, + asset: Doc<"imageAssets">, + now: number, +): Promise { + await ctx.db.patch(asset._id, { + uploadStatus: "deleted", + deletedAt: now, + cleanupClaimedAt: undefined, + updatedAt: now, + }); +} + +async function schedulePhysicalDeletion( + ctx: MutationCtx, + delayMs = 0, +): Promise { + await ctx.scheduler.runAfter(delayMs, sweepDeletedImageAssetsRef, {}); +} + +/// Queues images whose showing content was just purged (a deleted page's +/// rows, or tombstones past retention) for reclaim, in the purge's own +/// transaction, and starts the worker. The worker marks an image's assets +/// deleted only once no content, live or still-restorable, shows it. +export async function queueAssetReclaim( + ctx: MutationCtx, + strategyId: Id<"strategies">, + assetPublicIds: Iterable, +): Promise { + const added = await addAssetReclaimCandidates( + ctx, + strategyId, + assetPublicIds, + Date.now(), + ); + if (added > 0) { + await ctx.scheduler.runAfter(0, processAssetReclaimCandidatesRef, {}); + } +} + +// Earlier versions scheduled these two to reclaim a deleted page's or +// purged tombstones' images. Runs already scheduled when this version +// deploys land here and hand their images to the reclaim queue, which +// checks them the same way: a bounded slice per run (each costs an indexed +// read), with the rest rescheduled in the same transaction. +const compatibilityQueueSlice = 100; + +export const markDeletedPageImageAssets = internalMutation({ + args: { + strategyId: v.id("strategies"), + pageId: v.id("pages"), + assetPublicIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + await queueAssetReclaim( + ctx, + args.strategyId, + args.assetPublicIds.slice(0, compatibilityQueueSlice), + ); + const rest = args.assetPublicIds.slice(compatibilityQueueSlice); + if (rest.length > 0) { + await ctx.scheduler.runAfter(0, markDeletedPageImageAssetsRef, { + ...args, + assetPublicIds: rest, + }); + } + }, +}); + +export const markPurgedTombstoneImageAssets = internalMutation({ + args: { + strategyId: v.id("strategies"), + assetPublicIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + await queueAssetReclaim( + ctx, + args.strategyId, + args.assetPublicIds.slice(0, compatibilityQueueSlice), + ); + const rest = args.assetPublicIds.slice(compatibilityQueueSlice); + if (rest.length > 0) { + await ctx.scheduler.runAfter(0, markPurgedTombstoneImageAssetsRef, { + ...args, + assetPublicIds: rest, + }); + } + }, +}); + +export const generateUploadUrl = action({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + assetPublicId: v.string(), + mimeType: v.string(), + fileExtension: v.string(), + byteSize: v.optional(v.number()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + returns: v.object({ + provider: v.literal("r2"), + uploadId: v.string(), + objectKey: v.string(), + uploadUrl: v.string(), + requiredHeaders: v.record(v.string(), v.string()), + expiresAt: v.number(), + maxBytes: v.number(), + }), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const config = getR2Config(); + const validated = validateImageUploadMetadata({ + fileExtension: args.fileExtension, + mimeType: args.mimeType, + byteSize: args.byteSize, + maxImageBytes: config.maxImageBytes, + }); + const objectKey = createR2ObjectKey({ + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + fileExtension: validated.fileExtension, + }); + + const uploadAttemptPublicId = createUploadAttemptPublicId(); + const intent: { + uploadId: Id<"imageAssets">; + uploadAttemptPublicId: string; + objectKey: string; + issuedAt: number; + } = + await ctx.runMutation(internal.images.createR2UploadIntent, { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + objectKey, + uploadAttemptPublicId, + mimeType: validated.mimeType, + fileExtension: validated.fileExtension, + byteSize: args.byteSize, + width: args.width, + height: args.height, + }); + // Signed at the intent's time, so the URL expires exactly when the + // intent's uploadUrlExpiresAt says it does. + const signed = await presignR2PutUrl({ + config, + objectKey: intent.objectKey, + mimeType: validated.mimeType, + now: new Date(intent.issuedAt), + }); + + return { + provider: "r2" as const, + uploadId: intent.uploadAttemptPublicId, + objectKey: intent.objectKey, + uploadUrl: signed.uploadUrl, + requiredHeaders: signed.requiredHeaders, + expiresAt: signed.expiresAt, + maxBytes: config.maxImageBytes, + }; + }, +}); + +export const createR2UploadIntent = internalMutation({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + objectKey: v.string(), + uploadAttemptPublicId: v.string(), + mimeType: v.string(), + fileExtension: v.string(), + byteSize: v.optional(v.number()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user } = await assertStrategyRole(ctx, strategy, "editor"); + + const existingObject = await ctx.db + .query("imageAssets") + .withIndex("by_objectKey", (q) => q.eq("objectKey", args.objectKey)) + .first(); + if (existingObject !== null) { + throw conflictError("R2 object key collision. Retry the upload."); + } + + const now = Date.now(); + const upload = { + publicId: args.assetPublicId, + provider: "r2" as const, + strategyId: strategy._id, + createdByUserId: user._id, + objectKey: args.objectKey, + uploadAttemptPublicId: args.uploadAttemptPublicId, + uploadStatus: "pending" as const, + fileExtension: args.fileExtension, + mimeType: args.mimeType, + width: args.width, + height: args.height, + byteSize: args.byteSize, + uploadUrlExpiresAt: now + getUploadUrlExpiresSeconds() * 1000, + updatedAt: now, + }; + // Content that shows this image may have landed first and left a + // placeholder; the upload takes it over rather than adding a second row. + const placeholder = ( + await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId_and_uploadStatus", (q) => + q + .eq("strategyId", strategy._id) + .eq("publicId", args.assetPublicId) + .eq("uploadStatus", "pending"), + ) + .take(20) + ).find(isUploadPlaceholder); + let uploadId: Id<"imageAssets">; + if (placeholder !== undefined) { + await ctx.db.patch(placeholder._id, upload); + uploadId = placeholder._id; + } else { + uploadId = await ctx.db.insert("imageAssets", { + ...upload, + createdAt: now, + }); + } + + return { + uploadId, + uploadAttemptPublicId: args.uploadAttemptPublicId, + objectKey: args.objectKey, + issuedAt: now, + }; + }, +}); + +export const completeUpload = action({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + assetPublicId: v.string(), + provider: v.optional(imageProviderValidator), + uploadId: v.optional(v.string()), + objectKey: v.optional(v.string()), + storageId: v.optional(v.id("_storage")), + etag: v.optional(v.string()), + mimeType: v.optional(v.string()), + fileExtension: v.optional(v.string()), + byteSize: v.optional(v.number()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + returns: v.union( + v.object({ ok: v.literal(true), provider: v.literal("convex") }), + v.object({ + ok: v.literal(true), + provider: v.literal("r2"), + url: v.string(), + }), + ), + handler: async ( + ctx, + args, + ) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + if (args.storageId !== undefined || args.provider === "convex") { + await ctx.runMutation(internal.images.completeLegacyUpload, { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + storageId: args.storageId, + mimeType: args.mimeType, + fileExtension: args.fileExtension, + width: args.width, + height: args.height, + }); + return { ok: true, provider: "convex" } as const; + } + + if (args.uploadId === undefined) { + throw invalidPayloadError("Missing R2 uploadId for image completion."); + } + + const intent: { + uploadId: Id<"imageAssets">; + objectKey: string; + uploadStatus: UploadStatus; + fileExtension: string; + mimeType: string; + byteSize: number | null; + } = await ctx.runQuery(internal.images.getR2UploadIntentForCompletion, { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + uploadAttemptPublicId: args.uploadId, + }); + + if (args.objectKey !== undefined && args.objectKey !== intent.objectKey) { + throw errorWithCode( + "R2_OBJECT_KEY_MISMATCH", + "R2 object key does not match upload intent.", + ); + } + if (intent.uploadStatus === "active") { + return { + ok: true as const, + provider: "r2" as const, + url: publicR2UrlForObjectKey(intent.objectKey), + }; + } + + const config = getR2Config(); + const metadata = await headR2Object(config, intent.objectKey); + if (metadata === null) { + await ctx.runMutation(internal.images.markR2UploadFailed, { + uploadId: intent.uploadId, + reason: "R2 object was not found during completion.", + }); + throw notFoundError("Uploaded image", intent.objectKey); + } + + const expectedMimeType = + args.mimeType ?? + intent.mimeType ?? + expectedMimeTypeForExtension(intent.fileExtension); + const actualMimeType = + metadata.mimeType?.split(";")[0]?.trim().toLowerCase() ?? null; + const actualByteSize = metadata.byteSize; + if ( + actualByteSize === null || + actualByteSize <= 0 || + actualByteSize > config.maxImageBytes || + expectedMimeType === null || + actualMimeType !== expectedMimeType + ) { + await deleteR2Object(config, intent.objectKey); + await ctx.runMutation(internal.images.markR2UploadFailed, { + uploadId: intent.uploadId, + reason: "Uploaded R2 object failed size or MIME validation.", + }); + throw invalidPayloadError("Uploaded image failed size or MIME validation."); + } + + await ctx.runMutation(internal.images.markR2UploadActive, { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + uploadId: intent.uploadId, + byteSize: actualByteSize, + etag: metadata.etag ?? args.etag, + mimeType: actualMimeType, + fileExtension: args.fileExtension ?? intent.fileExtension, + width: args.width, + height: args.height, + }); + + return { + ok: true as const, + provider: "r2" as const, + url: publicR2UrlForObjectKey(intent.objectKey), + }; + }, +}); + +export const getR2UploadIntentForCompletion = internalQuery({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + uploadAttemptPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const asset = await ctx.db + .query("imageAssets") + .withIndex("by_uploadAttemptPublicId", (q) => + q.eq("uploadAttemptPublicId", args.uploadAttemptPublicId), + ) + .unique(); + if ( + asset === null || + asset.strategyId !== strategy._id || + asset.publicId !== args.assetPublicId || + inferProvider(asset) !== "r2" || + asset.objectKey === undefined || + inferUploadStatus(asset) === "deleted" + ) { + throw errorWithCode("UPLOAD_INTENT_NOT_FOUND", "Upload intent not found."); + } + + const fileExtension = inferFileExtension(asset); + const expectedMimeType = expectedMimeTypeForExtension(fileExtension); + if (expectedMimeType === null || asset.mimeType === undefined) { + throw invalidPayloadError("Upload intent has invalid image metadata."); + } + + return { + uploadId: asset._id, + objectKey: asset.objectKey, + uploadStatus: inferUploadStatus(asset), + fileExtension, + mimeType: asset.mimeType, + byteSize: asset.byteSize ?? null, + }; + }, +}); + +export const markR2UploadActive = internalMutation({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + uploadId: v.id("imageAssets"), + byteSize: v.number(), + etag: v.optional(v.string()), + mimeType: v.string(), + fileExtension: v.string(), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const asset = await ctx.db.get(args.uploadId); + if ( + asset === null || + asset.strategyId !== strategy._id || + asset.publicId !== args.assetPublicId || + inferProvider(asset) !== "r2" || + asset.objectKey === undefined || + inferUploadStatus(asset) === "deleted" + ) { + throw errorWithCode("UPLOAD_INTENT_NOT_FOUND", "Upload intent not found."); + } + + const now = Date.now(); + await ctx.db.patch(asset._id, { + uploadStatus: "active", + fileExtension: normalizeImageExtension(args.fileExtension), + mimeType: args.mimeType, + width: args.width ?? asset.width, + height: args.height ?? asset.height, + byteSize: args.byteSize, + etag: args.etag, + uploadedAt: now, + updatedAt: now, + }); + + const olderActiveAssets = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId_and_uploadStatus", (q) => + q + .eq("strategyId", strategy._id) + .eq("publicId", args.assetPublicId) + .eq("uploadStatus", "active"), + ) + .take(20); + let replaced = 0; + for (const olderAsset of olderActiveAssets) { + if (olderAsset._id === asset._id) { + continue; + } + await markImageAssetDeleted(ctx, olderAsset, now); + replaced += 1; + } + + if (replaced > 0) { + await schedulePhysicalDeletion(ctx); + } + return { ok: true as const, replaced }; + }, +}); + +export const markR2UploadFailed = internalMutation({ + args: { + uploadId: v.id("imageAssets"), + reason: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const asset = await ctx.db.get(args.uploadId); + if (asset === null || inferUploadStatus(asset) === "deleted") { + return { ok: true }; + } + await ctx.db.patch(asset._id, { + uploadStatus: "failed", + updatedAt: Date.now(), + }); + return { ok: true } as const; + }, +}); + +export const completeLegacyUpload = internalMutation({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + storageId: v.optional(v.id("_storage")), + mimeType: v.optional(v.string()), + fileExtension: v.optional(v.string()), + width: v.optional(v.number()), + height: v.optional(v.number()), + }, + handler: async (ctx, args) => { + if (args.storageId === undefined) { + throw internalError("Missing Convex storageId for legacy image completion."); + } + + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user } = await assertStrategyRole(ctx, strategy, "editor"); + const now = Date.now(); + const metadata = await ctx.db.system.get("_storage", args.storageId); + const existing = await getDeletionCandidateForStrategy( + ctx, + strategy._id, + args.assetPublicId, + ); + + if ( + existing !== null && + (existing.strategyId === undefined || existing.strategyId === strategy._id) && + inferProvider(existing) === "convex" + ) { + const previousStorageId = existing.storageId; + await ctx.db.patch(existing._id, { + provider: "convex", + strategyId: strategy._id, + createdByUserId: existing.createdByUserId ?? user._id, + storageId: args.storageId, + uploadStatus: "active", + fileExtension: args.fileExtension, + mimeType: args.mimeType ?? metadata?.contentType, + width: args.width, + height: args.height, + byteSize: metadata?.size, + uploadedAt: now, + updatedAt: now, + }); + // The replaced bytes go the way of every deletion: a row marked + // deleted for the physical sweep, which removes them only once nothing + // else points at them and the reference backfill has finished. + if ( + previousStorageId !== undefined && + previousStorageId !== args.storageId + ) { + await ctx.db.insert("imageAssets", { + publicId: args.assetPublicId, + provider: "convex", + strategyId: strategy._id, + storageId: previousStorageId, + uploadStatus: "deleted", + deletedAt: now, + createdAt: now, + updatedAt: now, + }); + await schedulePhysicalDeletion(ctx); + } + } else { + await ctx.db.insert("imageAssets", { + publicId: args.assetPublicId, + provider: "convex", + strategyId: strategy._id, + createdByUserId: user._id, + storageId: args.storageId, + uploadStatus: "active", + fileExtension: args.fileExtension, + mimeType: args.mimeType ?? metadata?.contentType, + width: args.width, + height: args.height, + byteSize: metadata?.size, + uploadedAt: now, + createdAt: now, + updatedAt: now, + }); + } + + return { ok: true, provider: "convex" as const }; + }, +}); + +export const listForStrategy = query({ + args: { + strategyPublicId: v.string(), + }, + returns: v.array(imageAssetValidator), + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const referencedAssetIds = await collectLiveAssetIds(ctx, strategy._id); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), + ), + ); + + return await Promise.all( + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), + ); + }, +}); + +/// At most this many images are asked about at once, so the answer reads a +/// bounded number of rows however much content the strategy has. +export const MAX_REFERENCED_ASSET_IDS_PER_QUERY = 100; + +/// Which of [assetPublicIds] the strategy's content shows, deleted content +/// left out but pages in the trash included: their images are still +/// wanted, since the page can be restored. A client asks before it drops an +/// upload it holds. Null until the reference backfill has finished, when it +/// cannot be told. +export const listReferencedAssetIds = query({ + args: { + strategyPublicId: v.string(), + assetPublicIds: v.array(v.string()), + }, + returns: v.union(v.array(v.string()), v.null()), + handler: async (ctx, args) => { + if (args.assetPublicIds.length > MAX_REFERENCED_ASSET_IDS_PER_QUERY) { + throw invalidPayloadError( + `Ask about at most ${MAX_REFERENCED_ASSET_IDS_PER_QUERY} images at once.`, + ); + } + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + if (!(await assetReferencesReady(ctx))) return null; + const referenced: string[] = []; + for (const assetPublicId of new Set(args.assetPublicIds)) { + const reference = await ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_assetPublicId_and_deleted", (q) => + q + .eq("strategyId", strategy._id) + .eq("assetPublicId", assetPublicId) + .eq("deleted", false), + ) + .first(); + if (reference !== null) referenced.push(assetPublicId); + } + return referenced.sort(); + }, +}); + +export const getAssetUrl = query({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + shareToken: v.optional(v.string()), + }, + returns: v.object({ url: v.union(v.string(), v.null()) }), + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyReadable(ctx, strategy, args.shareToken); + + if ( + !(await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) + ) { + throw notFoundError("Asset", args.assetPublicId); + } + + const asset = await getActiveAssetForStrategy( + ctx, + strategy._id, + args.assetPublicId, + ); + if (asset === null) { + throw notFoundError("Asset", args.assetPublicId); + } + + return { + url: + inferProvider(asset) === "r2" + ? asset.objectKey === undefined + ? null + : publicR2UrlForObjectKey(asset.objectKey) + : asset.storageId === undefined + ? null + : await ctx.storage.getUrl(asset.storageId), + }; + }, +}); + +export const deleteAssetRef = action({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + assetPublicId: v.string(), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const target: { assetId: Id<"imageAssets"> } = await ctx.runQuery( + internal.images.getAssetDeletionTarget, + { + strategyPublicId: args.strategyPublicId, + assetPublicId: args.assetPublicId, + }, + ); + + await ctx.runMutation(internal.images.markDeletedAssetRefsForStrategy, { + strategyPublicId: args.strategyPublicId, + assetIds: [target.assetId], + }); + return { ok: true } as const; + }, +}); + +export const getAssetDeletionTarget = internalQuery({ + args: { + strategyPublicId: v.string(), + assetPublicId: v.string(), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const asset = await getDeletionCandidateForStrategy( + ctx, + strategy._id, + args.assetPublicId, + ); + if (asset === null) { + throw notFoundError("Asset", args.assetPublicId); + } + + if (await strategyReferencesAsset(ctx, strategy._id, args.assetPublicId)) { + throw conflictError("Asset is still referenced by this Strategy."); + } + + return { assetId: asset._id }; + }, +}); + +export const markDeletedAssetRefsForStrategy = internalMutation({ + args: { + strategyPublicId: v.string(), + assetIds: v.array(v.id("imageAssets")), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const now = Date.now(); + let deleted = 0; + let shouldSweep = false; + for (const assetId of args.assetIds.slice(0, maxDeletionBatch)) { + const asset = await ctx.db.get(assetId); + if (asset === null) { + continue; + } + if ( + asset.strategyId !== strategy._id || + (await strategyReferencesAsset(ctx, strategy._id, asset.publicId)) + ) { + continue; + } + shouldSweep = true; + if (inferUploadStatus(asset) !== "deleted") { + await markImageAssetDeleted(ctx, asset, now); + deleted += 1; + } + } + if (shouldSweep) { + await schedulePhysicalDeletion(ctx); + } + return { ok: true, deleted }; + }, +}); + +/// Works through queued reclaim candidates, oldest first, a small batch per +/// run. A candidate's image has its assets marked deleted only when no +/// content of its strategy (live, or a tombstone undo may still restore) +/// shows it; physical deletion then follows the normal sweep. Each check is +/// an indexed lookup, so a run's reads do not grow with strategy size, and a +/// candidate leaves the queue only in the transaction that decides it: a +/// failed run leaves the whole batch queued for the next one (scheduled +/// here, and by the hourly cron). +export const processAssetReclaimCandidates = internalMutation({ + args: {}, + handler: async (ctx) => await processAssetReclaimBatch(ctx), +}); + +/// One run of [processAssetReclaimCandidates] (exported for the read-budget +/// test). +export async function processAssetReclaimBatch(ctx: MutationCtx) { + // Before the reference backfill, "no reference row" proves nothing: the + // candidates wait, and the backfill starts this worker when it finishes. + if (!(await assetReferencesReady(ctx))) return { checked: 0, marked: 0 }; + const candidates = await ctx.db + .query("assetReclaimCandidates") + .withIndex("by_createdAt") + .take(reclaimCandidateBatch); + const now = Date.now(); + let marked = 0; + for (const candidate of candidates) { + if ( + await isAssetReferenced(ctx, candidate.strategyId, candidate.assetPublicId, { + includeTombstones: true, + }) + ) { + await ctx.db.delete(candidate._id); + continue; + } + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId", (q) => + q + .eq("strategyId", candidate.strategyId) + .eq("publicId", candidate.assetPublicId), + ) + .filter((q) => q.neq(q.field("uploadStatus"), "deleted")) + .take(maxDeletionBatch); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + marked += 1; + } + // A full page may hide more rows for this image: keep the candidate for + // the next run. + if (assets.length < maxDeletionBatch) { + await ctx.db.delete(candidate._id); + } + } + if (marked > 0) { + await schedulePhysicalDeletion(ctx); + } + if (candidates.length === reclaimCandidateBatch) { + await ctx.scheduler.runAfter(0, processAssetReclaimCandidatesRef, {}); + } + return { checked: candidates.length, marked }; +} + +export const markDeletedStrategyImageAssets = internalMutation({ + args: { + strategyId: v.id("strategies"), + }, + handler: async (ctx, args) => { + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId", (q) => q.eq("strategyId", args.strategyId)) + .filter((q) => q.neq(q.field("uploadStatus"), "deleted")) + .take(maxDeletionBatch); + const now = Date.now(); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + } + if (assets.length > 0) { + await schedulePhysicalDeletion(ctx); + } + if (assets.length === maxDeletionBatch) { + await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, args); + } + return { ok: true as const, deleted: assets.length }; + }, +}); + +export const markStaleImageUploadsDeleted = internalMutation({ + args: { + staleBefore: v.optional(v.number()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const staleBefore = args.staleBefore ?? Date.now() - staleUploadAgeMs; + const limit = Math.max( + 1, + Math.min(args.limit ?? maxDeletionBatch, maxDeletionBatch), + ); + const stuckClaims = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", "deleted"), + ) + .filter((q) => + q.and( + q.neq(q.field("strategyId"), undefined), + q.neq(q.field("cleanupClaimedAt"), undefined), + q.lte( + q.field("cleanupClaimedAt"), + Date.now() - staleDeletionClaimAgeMs, + ), + ), + ) + .take(limit); + for (const asset of stuckClaims) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: undefined }); + } + + const assets: Doc<"imageAssets">[] = []; + for (const status of ["pending", "failed"] as UploadStatus[]) { + const remainingSlots = limit - stuckClaims.length - assets.length; + if (remainingSlots <= 0) { + break; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", status).lte("updatedAt", staleBefore), + ) + .filter((q) => q.neq(q.field("strategyId"), undefined)) + .take(remainingSlots); + assets.push(...matches); + } + + const now = Date.now(); + for (const asset of assets) { + await markImageAssetDeleted(ctx, asset, now); + } + if (assets.length > 0 || stuckClaims.length > 0) { + await schedulePhysicalDeletion(ctx); + } + if (assets.length + stuckClaims.length === limit) { + await ctx.scheduler.runAfter(0, markStaleImageUploadsDeletedRef, { + staleBefore, + limit, + }); + } + return { + ok: true as const, + deleted: assets.length, + released: stuckClaims.length, + }; + }, + }); + +async function hasSharedDeletionTarget( + ctx: QueryCtx | MutationCtx, + asset: Doc<"imageAssets">, +): Promise { + if (inferProvider(asset) === "r2") { + if (asset.objectKey === undefined) { + return false; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_objectKey", (q) => q.eq("objectKey", asset.objectKey)) + .take(2); + return matches.some((candidate) => candidate._id !== asset._id); + } + if (asset.storageId === undefined) { + return false; + } + const matches = await ctx.db + .query("imageAssets") + .withIndex("by_storageId", (q) => q.eq("storageId", asset.storageId)) + .take(2); + return matches.some((candidate) => candidate._id !== asset._id); +} + +export const claimDeletedImageAssets = internalMutation({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + // Every physical deletion of stored bytes goes through this claim. Until + // the reference backfill has finished, an asset may have been marked + // deleted on the say-so of missing references, so no bytes go; the + // backfill starts a sweep when it finishes. + if (!(await assetReferencesReady(ctx))) return []; + const limit = Math.max( + 1, + Math.min(args.limit ?? physicalDeletionBatch, physicalDeletionBatch), + ); + const now = Date.now(); + // An upload deleted while its PUT may still be in flight waits for the + // URL to die. Deleting first would let the bytes land afterwards with no + // row left to reclaim them; the hourly sweep picks the asset up later. + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_uploadStatus_and_updatedAt", (q) => + q.eq("uploadStatus", "deleted"), + ) + .filter((q) => + q.and( + q.neq(q.field("strategyId"), undefined), + q.eq(q.field("cleanupClaimedAt"), undefined), + q.or( + q.eq(q.field("uploadUrlExpiresAt"), undefined), + q.lte( + q.field("uploadUrlExpiresAt"), + now - uploadInFlightGraceMs, + ), + ), + ), + ) + .take(limit); + for (const asset of assets) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: now }); + } + return assets.map((asset) => asset._id); + }, +}); + +export const getDeletedImageAssetTarget = internalQuery({ + args: { + assetId: v.id("imageAssets"), + }, + handler: async (ctx, args): Promise => { + const asset = await ctx.db.get(args.assetId); + if ( + asset === null || + inferUploadStatus(asset) !== "deleted" || + asset.cleanupClaimedAt === undefined + ) { + return null; + } + return { + assetId: asset._id, + provider: inferProvider(asset), + objectKey: asset.objectKey ?? null, + sharedTarget: await hasSharedDeletionTarget(ctx, asset), + }; + }, +}); + +export const finalizeDeletedImageAsset = internalMutation({ + args: { + assetId: v.id("imageAssets"), + r2ObjectDeleted: v.boolean(), + }, + handler: async (ctx, args) => { + const asset = await ctx.db.get(args.assetId); + if (asset === null) { + return { ok: true as const, finalized: true }; + } + if (inferUploadStatus(asset) !== "deleted") { + return { ok: true as const, finalized: false }; + } + + const sharedTarget = await hasSharedDeletionTarget(ctx, asset); + if (inferProvider(asset) === "r2") { + if ( + asset.objectKey !== undefined && + !sharedTarget && + !args.r2ObjectDeleted + ) { + return { ok: true as const, finalized: false }; + } + } else if (asset.storageId !== undefined && !sharedTarget) { + await ctx.storage.delete(asset.storageId); + } + await ctx.db.delete(asset._id); + return { ok: true as const, finalized: true }; + }, +}); + +export const scheduleDeletedImageAssetSweep = internalMutation({ + args: { + delayMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const delayMs = Math.max(0, Math.min(args.delayMs ?? 0, 60 * 60 * 1000)); + await schedulePhysicalDeletion(ctx, delayMs); + return { ok: true as const }; + }, +}); + +export const releaseImageAssetDeletionClaims = internalMutation({ + args: { + assetIds: v.array(v.id("imageAssets")), + retryAfterMs: v.optional(v.number()), + }, + handler: async (ctx, args) => { + for (const assetId of args.assetIds.slice(0, physicalDeletionBatch)) { + const asset = await ctx.db.get(assetId); + if ( + asset !== null && + inferUploadStatus(asset) === "deleted" && + asset.cleanupClaimedAt !== undefined + ) { + await ctx.db.patch(asset._id, { cleanupClaimedAt: undefined }); + } + } + await schedulePhysicalDeletion( + ctx, + Math.max(0, args.retryAfterMs ?? deletionRetryDelayMs), + ); + return { ok: true as const }; + }, +}); + +export const sweepDeletedImageAssets = internalAction({ + args: { + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const limit = Math.max( + 1, + Math.min(args.limit ?? physicalDeletionBatch, physicalDeletionBatch), + ); + const assetIds: Id<"imageAssets">[] = await ctx.runMutation( + claimDeletedImageAssetsRef, + { limit }, + ); + let deleted = 0; + let failed = 0; + const failedAssetIds: Id<"imageAssets">[] = []; + let config: ReturnType | null = null; + + for (const assetId of assetIds) { + try { + const target: DeletionTarget | null = await ctx.runQuery( + getDeletedImageAssetTargetRef, + { assetId }, + ); + if (target === null) { + continue; + } + let r2ObjectDeleted = false; + if ( + target.provider === "r2" && + target.objectKey !== null && + !target.sharedTarget + ) { + config ??= getR2Config(); + await deleteR2Object(config, target.objectKey); + r2ObjectDeleted = true; + } + const result: { finalized: boolean } = await ctx.runMutation( + finalizeDeletedImageAssetRef, + { assetId, r2ObjectDeleted }, + ); + if (result.finalized) { + deleted += 1; + } else { + failed += 1; + failedAssetIds.push(assetId); + } + } catch { + failed += 1; + failedAssetIds.push(assetId); + } + } + + if (failedAssetIds.length > 0) { + await ctx.runMutation(releaseImageAssetDeletionClaimsRef, { + assetIds: failedAssetIds, + retryAfterMs: deletionRetryDelayMs, + }); + } else if (assetIds.length === limit) { + await ctx.runMutation(scheduleDeletedImageAssetSweepRef, { + delayMs: 0, + }); + } + return { ok: true as const, deleted, failed }; + }, +}); + +export const listPotentiallyStale = internalQuery({ + args: { + strategyPublicId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + const limit = Math.max(1, Math.min(args.limit ?? 200, 500)); + + const referencedAssetIds = await collectLiveAssetIds(ctx, strategy._id); + const assets = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .order("desc") + .take(limit); + + const candidates = assets.filter((asset) => { + const status = inferUploadStatus(asset); + if (status === "deleted") { + return false; + } + if (status === "pending" || status === "failed") { + return true; + } + return !referencedAssetIds.has(asset.publicId); + }); + + return await Promise.all( + candidates.map((asset) => serializeAssetForViewer(ctx, asset)), + ); + }, +}); diff --git a/convex/invites.ts b/convex/invites.ts new file mode 100644 index 00000000..f908b916 --- /dev/null +++ b/convex/invites.ts @@ -0,0 +1,253 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { + assertStrategyRole, + getStrategyRoleForUser, + higherCollaboratorRole, + requireCurrentUser, + type CollaboratorRole, +} from "./lib/auth"; +import { getStrategyByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + forbiddenError, + invalidOpError, + notFoundError, + errorWithCode, +} from "./lib/errors"; +import { + accessRoleValidator, + collaboratorRoleValidator, + okResultValidator, +} from "./lib/publicValidators"; + +const invitePreviewValidator = v.object({ + token: v.string(), + strategyPublicId: v.string(), + inviteRole: collaboratorRoleValidator, + hasAccessAlready: v.boolean(), + revoked: v.boolean(), + expiresAt: v.union(v.number(), v.null()), + createdAt: v.number(), +}); + +const inviteSummaryValidator = v.object({ + token: v.string(), + role: collaboratorRoleValidator, + createdAt: v.number(), + expiresAt: v.union(v.number(), v.null()), + revokedAt: v.union(v.number(), v.null()), + redeemed: v.boolean(), +}); + +export const get = query({ + args: { + token: v.optional(v.string()), + strategyPublicId: v.optional(v.string()), + }, + returns: v.union( + invitePreviewValidator, + v.array(inviteSummaryValidator), + v.null(), + ), + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + + if (args.token !== undefined) { + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token!)) + .first(); + if (invite === null) { + return null; + } + + const strategy = await ctx.db.get(invite.strategyId); + if (strategy === null) { + return null; + } + + const role = await getStrategyRoleForUser(ctx, strategy, user._id); + return { + token: invite.token, + strategyPublicId: strategy.publicId, + inviteRole: invite.role, + hasAccessAlready: role !== null, + revoked: invite.revokedAt !== undefined, + expiresAt: invite.expiresAt ?? null, + createdAt: invite.createdAt, + }; + } + + if (args.strategyPublicId !== undefined) { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "owner"); + + const invites = await ctx.db + .query("inviteTokens") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + + return invites + .sort((a, b) => b.createdAt - a.createdAt) + .map((invite) => ({ + token: invite.token, + role: invite.role, + createdAt: invite.createdAt, + expiresAt: invite.expiresAt ?? null, + revokedAt: invite.revokedAt ?? null, + redeemed: invite.redeemedByUserId !== undefined, + })); + } + + throw invalidOpError("Either token or strategyPublicId is required"); + }, +}); + +export const create = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + token: v.string(), + role: v.union(v.literal("editor"), v.literal("viewer")), + expiresAt: v.optional(v.number()), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user, role } = await assertStrategyRole(ctx, strategy, "owner"); + if (role !== "owner") { + throw forbiddenError(); + } + + const now = Date.now(); + await ctx.db.insert("inviteTokens", { + token: args.token, + strategyId: strategy._id, + role: args.role, + createdByUserId: user._id, + expiresAt: args.expiresAt, + createdAt: now, + updatedAt: now, + }); + + return { ok: true } as const; + }, +}); + +export const redeem = mutation({ + args: { + ...cloudProtocolArgs, + token: v.string(), + }, + returns: v.object({ + ok: v.literal(true), + strategyPublicId: v.string(), + role: accessRoleValidator, + }), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (invite === null) { + throw notFoundError("Invite", args.token); + } + + if (invite.revokedAt !== undefined) { + throw errorWithCode("INVITE_REVOKED", "Invite revoked"); + } + + if (invite.expiresAt !== undefined && invite.expiresAt < Date.now()) { + throw errorWithCode("INVITE_EXPIRED", "Invite expired"); + } + + const strategy = await ctx.db.get(invite.strategyId); + if (strategy === null) { + throw notFoundError("Strategy", invite.strategyId); + } + + let redeemedRole: CollaboratorRole = invite.role; + if (strategy.ownerId !== user._id) { + const existingMembership = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId_userId", (q) => + q.eq("strategyId", strategy._id).eq("userId", user._id), + ) + .first(); + + if (existingMembership === null) { + await ctx.db.insert("strategyCollaborators", { + strategyId: strategy._id, + userId: user._id, + role: invite.role, + invitedByUserId: invite.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + redeemedRole = higherCollaboratorRole( + existingMembership.role, + invite.role, + ); + if (redeemedRole !== existingMembership.role) { + await ctx.db.patch(existingMembership._id, { + role: redeemedRole, + updatedAt: Date.now(), + }); + } + } + } + + await ctx.db.patch(invite._id, { + redeemedByUserId: user._id, + updatedAt: Date.now(), + }); + + return { + ok: true, + strategyPublicId: strategy.publicId, + role: strategy.ownerId === user._id ? "owner" : redeemedRole, + } as const; + }, +}); + +export const revoke = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + token: v.string(), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "owner"); + if (role !== "owner") { + throw forbiddenError(); + } + + const invite = await ctx.db + .query("inviteTokens") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (invite === null || invite.strategyId !== strategy._id) { + throw notFoundError("Invite", args.token); + } + + await ctx.db.patch(invite._id, { + revokedAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true } as const; + }, +}); diff --git a/convex/lib/assetReferences.ts b/convex/lib/assetReferences.ts new file mode 100644 index 00000000..4e252b7e --- /dev/null +++ b/convex/lib/assetReferences.ts @@ -0,0 +1,294 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; +import { + collectAssetIdFromElementPayload, + collectAssetIdsFromLineupPayload, + isUploadPlaceholder, +} from "./imageAssets"; + +type AnyCtx = MutationCtx | QueryCtx; + +/// The fields of a content row its image references depend on, so a row +/// just written can be synced without reading it back. +export type ReferencingElement = Pick< + Doc<"elements">, + "strategyId" | "pageId" | "deleted" | "elementType" | "payload" +>; +export type ReferencingLineup = Pick< + Doc<"lineups">, + "strategyId" | "pageId" | "deleted" | "payload" +>; + +/// The backfill that gives content written before the reference table its +/// reference rows runs once per content table; each finished table is +/// recorded under its own name. +export type ContentTable = "elements" | "lineups"; +const backfillName = (table: ContentTable) => `assetReferences:${table}`; + +async function isBackfilled(ctx: AnyCtx, table: ContentTable) { + const row = await ctx.db + .query("completedBackfills") + .withIndex("by_name", (q) => q.eq("name", backfillName(table))) + .first(); + return row !== null; +} + +/// Whether every content row has its reference rows: true once +/// maintenance.backfillAssetReferences has finished both elements and +/// lineups, in either order. Until then a missing reference row proves +/// nothing, so nothing may be deleted on its say-so: no asset marked +/// deleted, and no stored bytes removed. +export async function assetReferencesReady(ctx: AnyCtx): Promise { + return ( + (await isBackfilled(ctx, "elements")) && + (await isBackfilled(ctx, "lineups")) + ); +} + +/// Records that the backfill of [table] has finished. Idempotent. +export async function markContentTableBackfilled( + ctx: MutationCtx, + table: ContentTable, +): Promise { + if (await isBackfilled(ctx, table)) return; + await ctx.db.insert("completedBackfills", { + name: backfillName(table), + completedAt: Date.now(), + }); +} + +/// Records both tables as backfilled (tests, and a fresh deployment). +export async function markAssetReferencesReady( + ctx: MutationCtx, +): Promise { + await markContentTableBackfilled(ctx, "elements"); + await markContentTableBackfilled(ctx, "lineups"); +} + +/// The image ids a content row shows, whether or not it is deleted: a +/// tombstone keeps referencing its image until it is purged. +export function assetIdsOfRow( + row: ReferencingElement | ReferencingLineup, +): Set { + if ("elementType" in row) { + if (row.elementType !== "image") return new Set(); + const assetId = collectAssetIdFromElementPayload(row.payload); + return new Set(assetId === null ? [] : [assetId]); + } + return collectAssetIdsFromLineupPayload(row.payload); +} + +/// Brings the reference rows of one element in step with it. Call after +/// every write to the element; pass the id with `null` after hard-deleting +/// it. Touches only this element's reference rows. With [maxWrites] it +/// stops after that many writes and reports whether the rows are fully in +/// step; syncing again continues (see syncRows). +export async function syncElementAssetReferences( + ctx: MutationCtx, + elementId: Id<"elements">, + element: ReferencingElement | null, + maxWrites = Infinity, +): Promise { + const existing = await ctx.db + .query("assetReferences") + .withIndex("by_elementId", (q) => q.eq("elementId", elementId)) + .collect(); + return await syncRows(ctx, existing, element, { elementId }, maxWrites); +} + +/// Brings the reference rows of one lineup row in step with it. Call after +/// every write to the row; pass the id with `null` after hard-deleting it. +/// [maxWrites] as for syncElementAssetReferences. +export async function syncLineupAssetReferences( + ctx: MutationCtx, + lineupId: Id<"lineups">, + lineup: ReferencingLineup | null, + maxWrites = Infinity, +): Promise { + const existing = await ctx.db + .query("assetReferences") + .withIndex("by_lineupId", (q) => q.eq("lineupId", lineupId)) + .collect(); + return await syncRows(ctx, existing, lineup, { lineupId }, maxWrites); +} + +export type SyncResult = { writes: number; complete: boolean }; + +/// Writes the reference rows of a content row inserted in this same +/// transaction, which has none yet, so nothing is read. +export async function insertAssetReferences( + ctx: MutationCtx, + source: { elementId: Id<"elements"> } | { lineupId: Id<"lineups"> }, + row: ReferencingElement | ReferencingLineup, +): Promise { + for (const assetPublicId of assetIdsOfRow(row)) { + await ctx.db.insert("assetReferences", { + strategyId: row.strategyId, + assetPublicId, + pageId: row.pageId, + ...source, + deleted: row.deleted, + }); + } +} + +/// Diffs a row's reference rows against the images it shows and writes the +/// difference, at most [maxWrites] writes. Every write moves the rows closer +/// to the wanted set, so a sync cut short is finished by syncing again. +async function syncRows( + ctx: MutationCtx, + existing: Doc<"assetReferences">[], + row: ReferencingElement | ReferencingLineup | null, + source: { elementId?: Id<"elements">; lineupId?: Id<"lineups"> }, + maxWrites: number, +): Promise { + const wanted = row === null ? new Set() : assetIdsOfRow(row); + const kept = new Set(); + let writes = 0; + const outOfWrites = () => writes++ >= maxWrites; + for (const reference of existing) { + if (row === null || !wanted.has(reference.assetPublicId) || + kept.has(reference.assetPublicId)) { + if (outOfWrites()) return { writes: maxWrites, complete: false }; + await ctx.db.delete(reference._id); + continue; + } + kept.add(reference.assetPublicId); + if (reference.deleted !== row.deleted || reference.pageId !== row.pageId) { + if (outOfWrites()) return { writes: maxWrites, complete: false }; + await ctx.db.patch(reference._id, { + deleted: row.deleted, + pageId: row.pageId, + }); + } + } + if (row === null) return { writes, complete: true }; + for (const assetPublicId of wanted) { + if (kept.has(assetPublicId)) continue; + if (outOfWrites()) return { writes: maxWrites, complete: false }; + await ctx.db.insert("assetReferences", { + strategyId: row.strategyId, + assetPublicId, + pageId: row.pageId, + ...source, + deleted: row.deleted, + }); + } + return { writes, complete: true }; +} + +/// Whether any content of the strategy shows the image: a live element or +/// lineup row, and, with [includeTombstones], a deleted one still inside +/// its retention window. One indexed read, whatever the strategy's size. +/// +/// Until the reference backfill has finished, every image counts as +/// referenced: content written before the table existed has no reference +/// rows yet, and keeping an image is always safe where deleting it is not. +export async function isAssetReferenced( + ctx: AnyCtx, + strategyId: Id<"strategies">, + assetPublicId: string, + { includeTombstones }: { includeTombstones: boolean }, +): Promise { + if (!(await assetReferencesReady(ctx))) return true; + const reference = includeTombstones + ? await ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_assetPublicId", (q) => + q.eq("strategyId", strategyId).eq("assetPublicId", assetPublicId), + ) + .first() + : await ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_assetPublicId_and_deleted", (q) => + q + .eq("strategyId", strategyId) + .eq("assetPublicId", assetPublicId) + .eq("deleted", false), + ) + .first(); + return reference !== null; +} + +/// Every image id the strategy's live content shows. Reads reference rows +/// only (one per image reference), never the content itself. +export async function collectLiveAssetIds( + ctx: AnyCtx, + strategyId: Id<"strategies">, +): Promise> { + const assetIds = new Set(); + const references = ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_deleted", (q) => + q.eq("strategyId", strategyId).eq("deleted", false), + ); + for await (const reference of references) { + assetIds.add(reference.assetPublicId); + } + return assetIds; +} + +/// Records images whose content was just purged, to be checked by the +/// reclaim worker (images.processAssetReclaimCandidates). Written in the +/// purge's own transaction, so the purge and the candidate land together. +export async function addAssetReclaimCandidates( + ctx: MutationCtx, + strategyId: Id<"strategies">, + assetPublicIds: Iterable, + now: number, +): Promise { + let added = 0; + for (const assetPublicId of new Set(assetPublicIds)) { + const existing = await ctx.db + .query("assetReclaimCandidates") + .withIndex("by_strategyId_and_assetPublicId", (q) => + q.eq("strategyId", strategyId).eq("assetPublicId", assetPublicId), + ) + .first(); + if (existing !== null) continue; + await ctx.db.insert("assetReclaimCandidates", { + strategyId, + assetPublicId, + createdAt: now, + }); + added += 1; + } + return added; +} + +/// Drops the upload placeholders among [assetPublicIds] (images deleted +/// elements showed) that no live content of the strategy shows any more. +/// Rows holding bytes are left to the normal asset lifecycle. +/// +/// Called once, after every op of a batch has applied (and synced its +/// reference rows), so the check sees the batch's final state: a lineup +/// added later in the same batch keeps the placeholder of the image it +/// shows. Each check is one indexed read. +export async function removeUploadPlaceholders( + ctx: MutationCtx, + strategyId: Id<"strategies">, + assetPublicIds: Iterable, +): Promise { + for (const publicId of new Set(assetPublicIds)) { + const placeholders = ( + await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId_and_uploadStatus", (q) => + q + .eq("strategyId", strategyId) + .eq("publicId", publicId) + .eq("uploadStatus", "pending"), + ) + .take(20) + ).filter(isUploadPlaceholder); + if (placeholders.length === 0) continue; + if ( + await isAssetReferenced(ctx, strategyId, publicId, { + includeTombstones: false, + }) + ) { + continue; + } + for (const row of placeholders) await ctx.db.delete(row._id); + } +} diff --git a/convex/lib/auth.ts b/convex/lib/auth.ts new file mode 100644 index 00000000..4b288f61 --- /dev/null +++ b/convex/lib/auth.ts @@ -0,0 +1,282 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { + errorWithCode, + forbiddenError, + internalError, + unauthenticatedError, +} from "./errors"; + +export type StrategyRole = "owner" | "editor" | "viewer"; +export type CollaboratorRole = Exclude; + +type AnyCtx = QueryCtx | MutationCtx; + +const roleRank: Record = { + viewer: 1, + editor: 2, + owner: 3, +}; + +export function getCanonicalExternalId(identity: { + tokenIdentifier: string; +}): string { + return identity.tokenIdentifier; +} + +export function getLegacyExternalId(identity: { + subject?: string | null; + tokenIdentifier: string; +}): string | null { + const subject = identity.subject; + if (subject == null || subject == identity.tokenIdentifier) { + return null; + } + return subject; +} + +export async function findUserByIdentity( + ctx: AnyCtx, + identity: { + subject?: string | null; + tokenIdentifier: string; + }, +): Promise | null> { + const canonicalExternalId = getCanonicalExternalId(identity); + const canonicalUser = await ctx.db + .query("users") + .withIndex("by_externalId", (q) => q.eq("externalId", canonicalExternalId)) + .first(); + + if (canonicalUser !== null) { + return canonicalUser; + } + + const legacyExternalId = getLegacyExternalId(identity); + if (legacyExternalId == null) { + return null; + } + + return await ctx.db + .query("users") + .withIndex("by_externalId", (q) => q.eq("externalId", legacyExternalId)) + .first(); +} + +export async function requireCurrentUser(ctx: AnyCtx): Promise> { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const user = await findUserByIdentity(ctx, identity); + + if (user === null) { + throw internalError( + "Missing user record. Call users:ensureCurrentUser before querying collaborative data.", + ); + } + + return user; +} + +/** + * Refuses a write bound to an account other than the caller's. + * [accountSubject] is the identity subject (the JWT `sub`) of the account + * the write belongs to; for Icarus it is the Supabase user id the client + * keys its outbox by. The refusal is FORBIDDEN, thrown, so nothing in the + * write is applied or recorded and the client keeps the work for its own + * account to send again. + */ +export async function assertCallerIsAccount( + ctx: AnyCtx, + accountSubject: string, +): Promise { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + if (identity.subject !== accountSubject) { + throw errorWithCode( + "FORBIDDEN", + "Forbidden: these changes belong to a different account", + ); + } +} + +export async function getStrategyRoleForUser( + ctx: AnyCtx, + strategy: Doc<"strategies">, + userId: Id<"users">, +): Promise { + if (strategy.ownerId === userId) { + return "owner"; + } + + const collaborator = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId_userId", (q) => + q.eq("strategyId", strategy._id).eq("userId", userId), + ) + .first(); + + return collaborator?.role ?? null; +} + +export async function getFolderRoleForUser( + ctx: AnyCtx, + folder: Doc<"folders">, + userId: Id<"users">, +): Promise { + if (folder.ownerId === userId) { + return "owner"; + } + + const collaborator = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId_userId", (q) => + q.eq("folderId", folder._id).eq("userId", userId), + ) + .first(); + + return collaborator?.role ?? null; +} + +export function higherRole( + left: StrategyRole | null, + right: StrategyRole | null, +): StrategyRole | null { + if (left === null) return right; + if (right === null) return left; + return roleRank[left] >= roleRank[right] ? left : right; +} + +export function higherCollaboratorRole( + left: CollaboratorRole, + right: CollaboratorRole, +): CollaboratorRole { + return higherRole(left, right) as CollaboratorRole; +} + +export async function getEffectiveFolderRoleForUser( + ctx: AnyCtx, + folder: Doc<"folders">, + userId: Id<"users">, +): Promise { + let current: Doc<"folders"> | null = folder; + let bestRole: StrategyRole | null = null; + + while (current !== null) { + bestRole = higherRole( + bestRole, + await getFolderRoleForUser(ctx, current, userId), + ); + const parentFolderId: Id<"folders"> | undefined = current.parentFolderId; + if (parentFolderId === undefined) { + break; + } + current = (await ctx.db.get(parentFolderId)) as Doc<"folders"> | null; + } + + return bestRole; +} + +export async function getEffectiveStrategyRoleForUser( + ctx: AnyCtx, + strategy: Doc<"strategies">, + userId: Id<"users">, +): Promise { + let bestRole = await getStrategyRoleForUser(ctx, strategy, userId); + + if (strategy.folderId !== undefined) { + const folder = await ctx.db.get(strategy.folderId); + if (folder !== null) { + bestRole = higherRole( + bestRole, + await getEffectiveFolderRoleForUser(ctx, folder, userId), + ); + } + } + + return bestRole; +} + +export function hasRole( + actual: StrategyRole | null, + required: StrategyRole, +): boolean { + if (actual === null) return false; + return roleRank[actual] >= roleRank[required]; +} + +export async function assertStrategyRole( + ctx: AnyCtx, + strategy: Doc<"strategies">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getEffectiveStrategyRoleForUser(ctx, strategy, user._id); + + if (!hasRole(role, required)) { + throw forbiddenError(); + } + + return { user, role: role as StrategyRole }; +} + +/** + * The role a reader holds on [strategy]: their own when they have one, + * otherwise viewer when [shareToken] is a live link to this strategy. A link + * lets anyone holding it look without an account; it never grants a write, + * and it stops working the moment its owner disables it. + */ +export async function assertStrategyReadable( + ctx: AnyCtx, + strategy: Doc<"strategies">, + shareToken: string | undefined, +): Promise { + if (shareToken === undefined) { + return (await assertStrategyRole(ctx, strategy, "viewer")).role; + } + + const identity = await ctx.auth.getUserIdentity(); + const user = + identity === null ? null : await findUserByIdentity(ctx, identity); + if (user !== null) { + const role = await getEffectiveStrategyRoleForUser(ctx, strategy, user._id); + if (role !== null) { + return role; + } + } + + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", shareToken)) + .first(); + if (link !== null && link.strategyId === strategy._id) { + if (link.revokedAt !== undefined) { + throw errorWithCode("SHARE_LINK_REVOKED", "Share link revoked"); + } + return "viewer"; + } + + if (identity === null) { + throw unauthenticatedError(); + } + throw forbiddenError(); +} + +export async function assertFolderRole( + ctx: AnyCtx, + folder: Doc<"folders">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getEffectiveFolderRoleForUser(ctx, folder, user._id); + + if (!hasRole(role, required)) { + throw forbiddenError(); + } + + return { user, role: role as StrategyRole }; +} diff --git a/convex/lib/canonicalValues.ts b/convex/lib/canonicalValues.ts new file mode 100644 index 00000000..99be643d --- /dev/null +++ b/convex/lib/canonicalValues.ts @@ -0,0 +1,24 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function normalizeComparableValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeComparableValue); + if (isRecord(value)) { + const result: Record = {}; + for (const key of Object.keys(value).sort()) { + if (value[key] !== undefined) { + result[key] = normalizeComparableValue(value[key]); + } + } + return result; + } + return value; +} + +export function valuesEqual(left: unknown, right: unknown): boolean { + return ( + JSON.stringify(normalizeComparableValue(left)) === + JSON.stringify(normalizeComparableValue(right)) + ); +} diff --git a/convex/lib/cloudProtocol.ts b/convex/lib/cloudProtocol.ts new file mode 100644 index 00000000..410181be --- /dev/null +++ b/convex/lib/cloudProtocol.ts @@ -0,0 +1,54 @@ +import { clientUpgradeRequiredError } from "./errors"; +import { v } from "convex/values"; + +// 4: Paranoia rows at payload version 2 (in-game size, data version 104). +// A client on 3 reads such a row as the old size and, on its next edit to +// the page, would write it back moved twice. Refusing 3 outright keeps +// those clients from reading or writing until they reload into 4. Every +// public query that returns element or lineup payloads, and the shell the +// editor opens with, takes the protocol too: a client that cannot read a +// row must not see one. +// 5: one row per lineup group, of kind "lineups": the lineups on a page +// joined through shared spots, with their origins, landings and links, +// replacing the origin, landing and link rows of 4. A client on 4 cannot +// read the new rows and writes rows the server no longer takes, so it is +// refused until it reloads into 5. +export const CURRENT_CLOUD_PROTOCOL_VERSION = 5; +export const MAX_CLOUD_OPERATION_BYTES = 900 * 1024; +export const MAX_CLOUD_ARRAY_ENTRIES = 8_000; +export const CLOUD_OPERATION_TOO_LARGE_MESSAGE = + "This saved change is too large for cloud sync. It remains saved on this device."; + +export const cloudProtocolArgs = { + clientProtocolVersion: v.number(), +} as const; + +export function assertSupportedCloudProtocol(clientProtocolVersion: number): void { + if (clientProtocolVersion !== CURRENT_CLOUD_PROTOCOL_VERSION) { + throw clientUpgradeRequiredError(); + } +} + +export function serializedConvexValueUtf8Bytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + +export function cloudOperationExceedsPolicy(value: unknown): boolean { + if (serializedConvexValueUtf8Bytes(value) > MAX_CLOUD_OPERATION_BYTES) { + return true; + } + return valueExceedsArrayPolicy(value); +} + +function valueExceedsArrayPolicy(value: unknown): boolean { + if (Array.isArray(value)) { + return ( + value.length > MAX_CLOUD_ARRAY_ENTRIES || + value.some(valueExceedsArrayPolicy) + ); + } + if (value !== null && typeof value === "object") { + return Object.values(value).some(valueExceedsArrayPolicy); + } + return false; +} diff --git a/convex/lib/entities.ts b/convex/lib/entities.ts new file mode 100644 index 00000000..bf75f433 --- /dev/null +++ b/convex/lib/entities.ts @@ -0,0 +1,252 @@ +import type { QueryCtx, MutationCtx } from "../_generated/server"; +import type { Doc, Id } from "../_generated/dataModel"; +import { notFoundError } from "./errors"; + +type AnyCtx = QueryCtx | MutationCtx; + +export async function getStrategyByPublicId( + ctx: AnyCtx, + strategyPublicId: string, +): Promise> { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + + if (strategy === null) { + throw notFoundError("Strategy", strategyPublicId); + } + + return strategy; +} + +export async function getFolderByPublicId( + ctx: AnyCtx, + folderPublicId: string, +): Promise> { + const folder = await ctx.db + .query("folders") + .withIndex("by_publicId", (q) => q.eq("publicId", folderPublicId)) + .first(); + + if (folder === null) { + throw notFoundError("Folder", folderPublicId); + } + + return folder; +} + +/// A live page. One in the trash reads as not found, as it did when a +/// delete removed the row. +export async function getPageByPublicId( + ctx: AnyCtx, + pagePublicId: string, +): Promise> { + const page = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .first(); + + if (page === null || isTrashed(page)) { + throw notFoundError("Page", pagePublicId); + } + + return page; +} + +/// How long a deleted page stays in the trash, restorable with everything on +/// it, before maintenance purges it for good (purgeTrashedPages). +export const PAGE_TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +/// Whether [page] is in the trash: deleted, not purged yet. +export function isTrashed(page: Doc<"pages">): boolean { + return page.deletedAt !== undefined; +} + +/// Whether [page] has been in the trash for the whole retention: it is due +/// for purging, or partly purged already, so it can no longer be restored. +export function isPastTrashRetention( + page: Doc<"pages">, + now: number, +): boolean { + return ( + page.deletedAt !== undefined && + page.deletedAt <= now - PAGE_TRASH_RETENTION_MS + ); +} + +/// Whether the strategy has pages in the trash, restorable or not yet purged. +export async function hasTrashedPages( + ctx: AnyCtx, + strategyId: Id<"strategies">, +): Promise { + const trashed = await ctx.db + .query("pages") + .withIndex("by_strategyId_and_deletedAt", (q) => + q.eq("strategyId", strategyId).gt("deletedAt", undefined), + ) + .first(); + return trashed !== null; +} + +/// The strategy's pages in the trash that can still be restored, most +/// recently deleted first. +export async function listRestorablePages( + ctx: AnyCtx, + strategyId: Id<"strategies">, + now: number, +): Promise[]> { + // Past the retention a page is due for purging (isPastTrashRetention). + return await ctx.db + .query("pages") + .withIndex("by_strategyId_and_deletedAt", (q) => + q + .eq("strategyId", strategyId) + .gt("deletedAt", now - PAGE_TRASH_RETENTION_MS), + ) + .order("desc") + .collect(); +} + +/// The strategy's pages, leaving out those in the trash. Read through an +/// index, so pages in the trash cost nothing here. +export function livePagesQuery(ctx: AnyCtx, strategyId: Id<"strategies">) { + return ctx.db + .query("pages") + .withIndex("by_strategyId_and_deletedAt", (q) => + q.eq("strategyId", strategyId).eq("deletedAt", undefined), + ); +} + +export async function listLivePages( + ctx: AnyCtx, + strategyId: Id<"strategies">, +): Promise[]> { + return await livePagesQuery(ctx, strategyId).collect(); +} + +/// The element rows, tombstones included, on [pages]. Read page by page, +/// so content in the trash is never read, however much of it the strategy +/// keeps. +export async function elementsOnPages( + ctx: AnyCtx, + pages: Doc<"pages">[], +): Promise[]> { + const byPage = await Promise.all( + pages.map((page) => + ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ), + ); + return byPage.flat(); +} + +/// The lineup rows on [pages]; see [elementsOnPages]. +export async function lineupsOnPages( + ctx: AnyCtx, + pages: Doc<"pages">[], +): Promise[]> { + const byPage = await Promise.all( + pages.map((page) => + ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ), + ); + return byPage.flat(); +} + +/// Stores [ordered] as the strategy's page order: each page's sortIndex +/// becomes its position, and an auto-named page is named for it. Only pages +/// that change are written, each with a new revision. +export async function writePageOrder( + ctx: MutationCtx, + ordered: Doc<"pages">[], + now: number, +): Promise { + for (let index = 0; index < ordered.length; index += 1) { + const page = ordered[index]!; + const name = page.isAutoNamed === true ? `Page ${index + 1}` : page.name; + if (page.sortIndex !== index || page.name !== name) { + await ctx.db.patch(page._id, { + name, + sortIndex: index, + revision: page.revision + 1, + updatedAt: now, + }); + } + } +} + +/// Moves [page] to the trash, deleted by [deletedBy]. Its rows stay, so it +/// can be restored with everything on it; it keeps its sortIndex, the place +/// it goes back to. The live pages left close the gap. [livePages] includes +/// [page]. +export async function trashPage( + ctx: MutationCtx, + page: Doc<"pages">, + livePages: Doc<"pages">[], + deletedBy: Id<"users">, + now: number, +): Promise { + await ctx.db.patch(page._id, { deletedAt: now, deletedBy }); + await writePageOrder( + ctx, + sortByNumberField( + livePages.filter((candidate) => candidate._id !== page._id), + "sortIndex", + ), + now, + ); +} + +/// Takes [page] out of the trash, back at the place it was deleted from, or +/// last if fewer pages are left than that. +export async function restoreTrashedPage( + ctx: MutationCtx, + page: Doc<"pages">, + livePages: Doc<"pages">[], + now: number, +): Promise { + const ordered = sortByNumberField(livePages, "sortIndex"); + ordered.splice(clampPageIndex(page.sortIndex, ordered.length), 0, page); + await ctx.db.patch(page._id, { deletedAt: undefined, deletedBy: undefined }); + await writePageOrder(ctx, ordered, now); +} + +export async function getElementByPublicId( + ctx: AnyCtx, + elementPublicId: string, +): Promise> { + const element = await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", elementPublicId)) + .first(); + + if (element === null) { + throw notFoundError("Element", elementPublicId); + } + + return element; +} + +export function sortByNumberField>( + input: T[], + field: keyof T, +): T[] { + return [...input].sort((a, b) => { + const fieldDifference = Number(a[field] ?? 0) - Number(b[field] ?? 0); + if (fieldDifference !== 0) return fieldDifference; + + const leftPublicId = typeof a.publicId === "string" ? a.publicId : ""; + const rightPublicId = typeof b.publicId === "string" ? b.publicId : ""; + return leftPublicId.localeCompare(rightPublicId); + }); +} + +export function clampPageIndex(index: number, maximum: number): number { + return Math.max(0, Math.min(Math.trunc(index), maximum)); +} diff --git a/convex/lib/errors.ts b/convex/lib/errors.ts new file mode 100644 index 00000000..8d3897f4 --- /dev/null +++ b/convex/lib/errors.ts @@ -0,0 +1,95 @@ +import { ConvexError } from 'convex/values'; + +export const errorCodes = [ + "CLIENT_UPGRADE_REQUIRED", + "CONFLICT", + "ELEMENT_STRATEGY_MISMATCH", + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "FORBIDDEN", + "INTERNAL_ERROR", + "INVALID_ELEMENT_PAYLOAD_DATA", + "INVALID_ELEMENT_PAYLOAD_KIND", + "INVALID_ELEMENT_PAYLOAD_VERSION", + "INVALID_LINEUP_PAYLOAD_DATA", + "INVALID_LINEUP_PAYLOAD_KIND", + "INVALID_LINEUP_PAYLOAD_VERSION", + "INVALID_OP", + "INVALID_PAGE_CONTENT_COUNT", + "INVALID_PAYLOAD", + "INVITE_EXPIRED", + "INVITE_REVOKED", + "LINEUP_PAGE_MISMATCH", + "LINEUP_STRATEGY_MISMATCH", + "MISSING_ADD_ELEMENT_ARGS", + "MISSING_ADD_LINEUP_ARGS", + "MISSING_ELEMENT_PAYLOAD", + "MISSING_ENTITY_PUBLIC_ID", + "MISSING_LINEUP_PAYLOAD", + "MISSING_PAGE_ID", + "MISSING_PAGE_PUBLIC_ID", + "NOT_FOUND", + "PAGE_DELETED", + "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP", + "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + "PAGE_STRATEGY_MISMATCH", + "R2_OBJECT_KEY_MISMATCH", + "SHARE_LINK_REVOKED", + "STRATEGY_TOO_LARGE_TO_DUPLICATE", + "UNAUTHENTICATED", + "UNSUPPORTED_OP", + "UPLOAD_INTENT_NOT_FOUND", +] as const; + +export type ErrorCode = (typeof errorCodes)[number]; + +type ErrorData = { + code: ErrorCode; + message: string; +}; + +function makeError(code: ErrorCode, message: string): ConvexError { + return new ConvexError({ code, message }); +} + +export function unauthenticatedError(): ConvexError { + return makeError("UNAUTHENTICATED", "Unauthenticated"); +} + +export function forbiddenError(): ConvexError { + return makeError("FORBIDDEN", "Forbidden"); +} + +export function notFoundError(entity: string, id: string): ConvexError { + return makeError("NOT_FOUND", `${entity} not found: ${id}`); +} + +/// A change to a page in the trash, refused until the page is restored. The +/// client matches this text (pageDeletedMessage) to send the change again +/// once it is. +export function pageDeletedError(): ConvexError { + return makeError("PAGE_DELETED", "This page was deleted"); +} + +export function clientUpgradeRequiredError(): ConvexError { + return makeError("CLIENT_UPGRADE_REQUIRED", "Client upgrade required"); +} + +export function invalidPayloadError(detail: string): ConvexError { + return makeError("INVALID_PAYLOAD", detail); +} + +export function conflictError(detail: string): ConvexError { + return makeError("CONFLICT", detail); +} + +export function invalidOpError(detail: string): ConvexError { + return makeError("INVALID_OP", detail); +} + +export function internalError(detail: string): ConvexError { + return makeError("INTERNAL_ERROR", detail); +} + +export function errorWithCode(code: ErrorCode, detail: string): ConvexError { + return makeError(code, detail); +} diff --git a/convex/lib/imageAssets.ts b/convex/lib/imageAssets.ts new file mode 100644 index 00000000..38ad835a --- /dev/null +++ b/convex/lib/imageAssets.ts @@ -0,0 +1,317 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; +import { normalizeImageExtension, publicR2UrlForObjectKey } from "./r2"; + +type AnyCtx = MutationCtx | QueryCtx; + +export type Provider = "convex" | "r2"; +export type UploadStatus = "pending" | "active" | "failed" | "deleted"; + +export type SerializedAsset = { + publicId: string; + provider: Provider; + uploadStatus: UploadStatus; + fileExtension: string; + mimeType: string | null; + width: number | null; + height: number | null; + byteSize: number | null; + uploadedAt: number | null; + url: string | null; + legacyStoragePath: string | null; +}; + +export function inferProvider(asset: Doc<"imageAssets">): Provider { + return asset.provider ?? "convex"; +} + +export function inferUploadStatus(asset: Doc<"imageAssets">): UploadStatus { + if (asset.uploadStatus !== undefined) { + return asset.uploadStatus; + } + return asset.storageId !== undefined || asset.storagePath !== undefined + ? "active" + : "pending"; +} + +export function inferFileExtension( + asset: Pick, "fileExtension" | "storagePath">, +): string { + if (asset.fileExtension !== undefined && asset.fileExtension.length > 0) { + return normalizeImageExtension(asset.fileExtension); + } + + const legacyPath = asset.storagePath ?? ""; + const match = legacyPath.match(/(\.[A-Za-z0-9]+)(?:$|[?#])/); + return match?.[1]?.toLowerCase() ?? ""; +} + +export function collectAssetIdFromElementPayload( + payload: Doc<"elements">["payload"], +): string | null { + return typeof payload.data.id === "string" ? payload.data.id : null; +} + +/// The images a lineup group shows: links[*].images[*].id, across every +/// lineup in it. Its origins and landings hold none. +export function collectAssetIdsFromLineupPayload( + payload: Doc<"lineups">["payload"], +): Set { + const assetIds = new Set(); + const links = payload.data.links; + if (!Array.isArray(links)) return assetIds; + for (const link of links) { + const images = isObject(link) ? link.images : undefined; + if (!Array.isArray(images)) continue; + for (const image of images) { + if (isObject(image) && typeof image.id === "string") { + assetIds.add(image.id); + } + } + } + return assetIds; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function collectReferencedAssetIds( + elements: Doc<"elements">[], + lineups: Doc<"lineups">[], +): Set { + const assetIds = new Set(); + + for (const element of elements) { + if (element.deleted || element.elementType !== "image") { + continue; + } + + const assetId = collectAssetIdFromElementPayload(element.payload); + if (assetId !== null) { + assetIds.add(assetId); + } + } + + for (const lineup of lineups) { + if (lineup.deleted) { + continue; + } + + for (const assetId of collectAssetIdsFromLineupPayload(lineup.payload)) { + assetIds.add(assetId); + } + } + + return assetIds; +} + +/// How long a pending upload may wait before the stale-upload sweep removes +/// it. +export const staleUploadAgeMs = 24 * 60 * 60 * 1000; + + +export function isVisibleAsset(asset: Doc<"imageAssets">): boolean { + if (inferUploadStatus(asset) !== "active") { + return false; + } + if (inferProvider(asset) === "r2") { + return asset.objectKey !== undefined && asset.objectKey.length > 0; + } + return asset.storageId !== undefined; +} + +export async function getActiveAssetForStrategy( + ctx: AnyCtx, + strategyId: Id<"strategies">, + assetPublicId: string, +): Promise | null> { + const strategyAsset = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId_and_uploadStatus", (q) => + q + .eq("strategyId", strategyId) + .eq("publicId", assetPublicId) + .eq("uploadStatus", "active"), + ) + .order("desc") + .first(); + + if (strategyAsset !== null && isVisibleAsset(strategyAsset)) { + return strategyAsset; + } + + const legacyCandidates = await ctx.db + .query("imageAssets") + .withIndex("by_publicId", (q) => q.eq("publicId", assetPublicId)) + .order("desc") + .take(20); + return ( + legacyCandidates.find( + (asset) => + (asset.strategyId === undefined || asset.strategyId === strategyId) && + isVisibleAsset(asset), + ) ?? null + ); +} + +/// A row recording that a strategy's content shows an image whose upload has +/// not started: pending, with no bytes anywhere yet. The upload intent adopts +/// it; the stale-upload sweep removes it if the upload never comes. +export function isUploadPlaceholder(asset: Doc<"imageAssets">): boolean { + return ( + inferUploadStatus(asset) === "pending" && + asset.objectKey === undefined && + asset.storageId === undefined + ); +} + +/// The image ids a live element or lineup row shows. +export function referencedAssetIds( + row: Doc<"elements"> | Doc<"lineups"> | null, +): Set { + if (row === null || row.deleted) return new Set(); + if ("elementType" in row) { + if (row.elementType !== "image") return new Set(); + const assetId = collectAssetIdFromElementPayload(row.payload); + return new Set(assetId === null ? [] : [assetId]); + } + return collectAssetIdsFromLineupPayload(row.payload); +} + +/// Records that the strategy's content now shows these images. Content and +/// its upload reach the server independently, so an image can be referenced +/// before its upload intent exists; without a row, readers could not tell an +/// image that is on its way from one that will never come. +export async function expectAssets( + ctx: MutationCtx, + strategyId: Id<"strategies">, + assetPublicIds: Iterable, + now: number, +): Promise { + for (const publicId of assetPublicIds) { + if ( + (await getViewerAssetForStrategy(ctx, strategyId, publicId)) !== null + ) { + continue; + } + await ctx.db.insert("imageAssets", { + publicId, + strategyId, + uploadStatus: "pending", + createdAt: now, + updatedAt: now, + }); + } +} + +/// Gives `targetStrategyId` its own active row for the image the source +/// strategy shows under `sourceAssetPublicId`, stored as `targetAssetPublicId`. +/// The row points at the same R2 object or Convex storage file: physical +/// cleanup only deletes bytes once no row points at them +/// (`hasSharedDeletionTarget` in images.ts), so each row is one reference. +/// Only an active source row is copied; a deleted one may already be mid-sweep. +/// "uploading" means the source's image has not finished uploading, so there +/// is nothing to copy yet; "unavailable" means the source cannot show it either. +export async function copyActiveAssetToStrategy( + ctx: MutationCtx, + args: { + sourceStrategyId: Id<"strategies">; + sourceAssetPublicId: string; + targetStrategyId: Id<"strategies">; + targetAssetPublicId: string; + userId: Id<"users">; + now: number; + }, +): Promise<"copied" | "uploading" | "unavailable"> { + const source = await getActiveAssetForStrategy( + ctx, + args.sourceStrategyId, + args.sourceAssetPublicId, + ); + if (source === null) { + const pending = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId_and_uploadStatus", (q) => + q + .eq("strategyId", args.sourceStrategyId) + .eq("publicId", args.sourceAssetPublicId) + .eq("uploadStatus", "pending"), + ) + .first(); + return pending === null ? "unavailable" : "uploading"; + } + await ctx.db.insert("imageAssets", { + publicId: args.targetAssetPublicId, + provider: inferProvider(source), + strategyId: args.targetStrategyId, + createdByUserId: args.userId, + storageId: source.storageId, + objectKey: source.objectKey, + uploadStatus: "active", + fileExtension: source.fileExtension, + mimeType: source.mimeType, + width: source.width, + height: source.height, + byteSize: source.byteSize, + etag: source.etag, + uploadedAt: source.uploadedAt, + storagePath: source.storagePath, + createdAt: args.now, + updatedAt: args.now, + }); + return "copied"; +} + +export async function getViewerAssetForStrategy( + ctx: AnyCtx, + strategyId: Id<"strategies">, + assetPublicId: string, +): Promise | null> { + const strategyCandidates = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId", (q) => + q.eq("strategyId", strategyId).eq("publicId", assetPublicId), + ) + .order("desc") + .take(20); + const strategyAsset = + strategyCandidates.find( + (asset) => inferUploadStatus(asset) !== "deleted", + ) ?? null; + if (strategyAsset !== null) { + return strategyAsset; + } + + return await getActiveAssetForStrategy(ctx, strategyId, assetPublicId); +} + +export async function serializeAssetForViewer( + ctx: QueryCtx, + asset: Doc<"imageAssets">, +): Promise { + const provider = inferProvider(asset); + const uploadStatus = inferUploadStatus(asset); + const url = + provider === "r2" + ? asset.objectKey === undefined || uploadStatus !== "active" + ? null + : publicR2UrlForObjectKey(asset.objectKey) + : asset.storageId === undefined || uploadStatus !== "active" + ? null + : await ctx.storage.getUrl(asset.storageId); + + return { + publicId: asset.publicId, + provider, + uploadStatus, + fileExtension: inferFileExtension(asset), + mimeType: asset.mimeType ?? null, + width: asset.width ?? null, + height: asset.height ?? null, + byteSize: asset.byteSize ?? null, + uploadedAt: asset.uploadedAt ?? null, + url, + legacyStoragePath: asset.storagePath ?? null, + }; +} diff --git a/convex/lib/lineupItems.ts b/convex/lib/lineupItems.ts new file mode 100644 index 00000000..43cdee40 --- /dev/null +++ b/convex/lib/lineupItems.ts @@ -0,0 +1,95 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx } from "../_generated/server"; +import { errorWithCode } from "./errors"; + +type LineupPayload = Doc<"lineups">["payload"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/// The lineups and spots a lineup group row holds, each kind of id in its +/// own namespace (a landing may share its lineup's id). +export function lineupGroupItems(payload: LineupPayload): Set { + const items = new Set(); + for (const [field, kind] of [ + ["origins", "origin"], + ["landings", "landing"], + ["links", "link"], + ] as const) { + const entries = payload.data[field]; + if (!Array.isArray(entries)) continue; + for (const entry of entries) { + if (isRecord(entry) && typeof entry.id === "string") { + items.add(`${kind}:${entry.id}`); + } + } + } + return items; +} + +/// Brings the lineupItems rows of one lineup group in step with it: one per +/// lineup and spot while the group is live, none otherwise. Call after every +/// write of a lineup row, with null once the row itself is gone. +export async function syncLineupItems( + ctx: MutationCtx, + lineupId: Id<"lineups">, + lineup: Pick, "pageId" | "deleted" | "payload"> | null, +): Promise { + const existing = await ctx.db + .query("lineupItems") + .withIndex("by_lineupId", (q) => q.eq("lineupId", lineupId)) + .collect(); + const wanted = + lineup === null || lineup.deleted + ? new Set() + : lineupGroupItems(lineup.payload); + for (const row of existing) { + if (lineup !== null && wanted.delete(row.item)) { + if (row.pageId !== lineup.pageId) { + await ctx.db.patch(row._id, { pageId: lineup.pageId }); + } + continue; + } + // An item the group no longer holds, a second row for one, or a row + // that is gone. + await ctx.db.delete(row._id); + } + if (lineup === null) return; + for (const item of wanted) { + await ctx.db.insert("lineupItems", { + pageId: lineup.pageId, + lineupId, + item, + }); + } +} + +/// Refuses a lineup group row ([lineupId], or a row about to be inserted +/// when null) that would hold a lineup or spot another live row of its page +/// holds. Clients never write one: a lineup stays in its group, and a new +/// one is aimed at one existing spot at most. Two rows sharing one would +/// each carry their own copy of it, which can drift apart, so the write +/// fails loudly instead. Reads only the small lineupItems rows, never the +/// lineup rows, whose image lists can be large. +export async function assertLineupGroupAlone( + ctx: MutationCtx, + pageId: Id<"pages">, + lineupId: Id<"lineups"> | null, + payload: LineupPayload, +): Promise { + for (const item of lineupGroupItems(payload)) { + const holders = await ctx.db + .query("lineupItems") + .withIndex("by_pageId_and_item", (q) => + q.eq("pageId", pageId).eq("item", item), + ) + .take(2); + if (holders.some((holder) => holder.lineupId !== lineupId)) { + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_DATA", + "A lineup or spot in this group is already in another group", + ); + } + } +} diff --git a/convex/lib/opTypes.ts b/convex/lib/opTypes.ts new file mode 100644 index 00000000..53e1edc5 --- /dev/null +++ b/convex/lib/opTypes.ts @@ -0,0 +1,244 @@ +import { v, type Infer } from "convex/values"; +import { + elementPayloadValidator, + lineupOpPayloadValidator, + lineupPayloadValidator, + mapThemePaletteValidator, + pagePayloadValidator, + strategyPatchPayloadValidator, + strategySettingsValidator, +} from "./payloadValidators"; + +const strategyPatchOpValidator = v.object({ + opId: v.string(), + type: v.literal("strategy.patch"), + payload: strategyPatchPayloadValidator, + expectedStrategyRevision: v.number(), +}); + +const pageAddOpValidator = v.object({ + opId: v.string(), + type: v.literal("page.add"), + pagePublicId: v.string(), + payload: pagePayloadValidator, + sortIndex: v.number(), + expectedStrategyRevision: v.number(), +}); + +const pagePatchOpValidator = v.object({ + opId: v.string(), + type: v.literal("page.patch"), + pagePublicId: v.string(), + payload: pagePayloadValidator, + expectedPageRevision: v.number(), +}); + +const pageDeleteOpValidator = v.object({ + opId: v.string(), + type: v.literal("page.delete"), + pagePublicId: v.string(), + expectedStrategyRevision: v.number(), +}); + +const pageReorderOpValidator = v.object({ + opId: v.string(), + type: v.literal("page.reorder"), + pagePublicId: v.string(), + sortIndex: v.number(), + expectedStrategyRevision: v.number(), +}); + +const pageContentPatchOpValidator = v.object({ + opId: v.string(), + type: v.literal("pageContent.patch"), + pagePublicId: v.string(), + settings: strategySettingsValidator, + expectedPageContentRevision: v.number(), +}); + +const elementAddOpValidator = v.object({ + opId: v.string(), + type: v.literal("element.add"), + elementPublicId: v.string(), + pagePublicId: v.string(), + payload: elementPayloadValidator, + sortIndex: v.number(), + expectedElementRevision: v.optional(v.number()), +}); + +const elementPatchOpValidator = v.object({ + opId: v.string(), + type: v.literal("element.patch"), + elementPublicId: v.string(), + pagePublicId: v.optional(v.string()), + payload: v.optional(elementPayloadValidator), + sortIndex: v.optional(v.number()), + expectedElementRevision: v.number(), +}); + +const elementDeleteOpValidator = v.object({ + opId: v.string(), + type: v.literal("element.delete"), + elementPublicId: v.string(), + pagePublicId: v.string(), + expectedElementRevision: v.number(), +}); + +const elementReorderOpValidator = v.object({ + opId: v.string(), + type: v.literal("element.reorder"), + elementPublicId: v.string(), + pagePublicId: v.string(), + sortIndex: v.number(), + expectedElementRevision: v.number(), +}); + +const lineupAddOpValidator = v.object({ + opId: v.string(), + type: v.literal("lineup.add"), + lineupPublicId: v.string(), + pagePublicId: v.string(), + payload: lineupOpPayloadValidator, + sortIndex: v.number(), + expectedLineupRevision: v.optional(v.number()), +}); + +const lineupPatchOpValidator = v.object({ + opId: v.string(), + type: v.literal("lineup.patch"), + lineupPublicId: v.string(), + pagePublicId: v.optional(v.string()), + payload: v.optional(lineupOpPayloadValidator), + sortIndex: v.optional(v.number()), + expectedLineupRevision: v.number(), +}); + +const lineupDeleteOpValidator = v.object({ + opId: v.string(), + type: v.literal("lineup.delete"), + lineupPublicId: v.string(), + pagePublicId: v.string(), + expectedLineupRevision: v.number(), +}); + +const lineupReorderOpValidator = v.object({ + opId: v.string(), + type: v.literal("lineup.reorder"), + lineupPublicId: v.string(), + pagePublicId: v.string(), + sortIndex: v.number(), + expectedLineupRevision: v.number(), +}); + +export const strategyOpValidator = v.union( + strategyPatchOpValidator, + pageAddOpValidator, + pagePatchOpValidator, + pageDeleteOpValidator, + pageReorderOpValidator, + pageContentPatchOpValidator, + elementAddOpValidator, + elementPatchOpValidator, + elementDeleteOpValidator, + elementReorderOpValidator, + lineupAddOpValidator, + lineupPatchOpValidator, + lineupDeleteOpValidator, + lineupReorderOpValidator, +); + +export type StrategyOp = Infer; + +export const opRejectionReasonValidator = v.union( + v.literal("already_exists"), + // A patch or reorder of a row a teammate deleted: only an add with the + // tombstone's revision brings it back. + v.literal("deleted"), + v.literal("element_strategy_mismatch"), + v.literal("lineup_strategy_mismatch"), + v.literal("missing_expected_revision"), + v.literal("not_found"), + v.literal("page_strategy_mismatch"), + v.literal("revision_mismatch"), +); + +const strategyCurrentValidator = v.object({ + type: v.literal("strategy"), + revision: v.number(), + value: v.object({ + name: v.string(), + mapData: v.string(), + themeProfileId: v.union(v.string(), v.null()), + themeOverridePalette: v.union(mapThemePaletteValidator, v.null()), + }), +}); + +const pageCurrentValidator = v.object({ + type: v.literal("page"), + revision: v.number(), + value: v.object({ + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + isAttack: v.boolean(), + sortIndex: v.number(), + }), +}); + +const pageContentCurrentValidator = v.object({ + type: v.literal("pageContent"), + revision: v.number(), + value: v.object({ + settings: v.union(strategySettingsValidator, v.null()), + }), +}); + +const elementCurrentValidator = v.object({ + type: v.literal("element"), + revision: v.number(), + value: elementPayloadValidator, +}); + +const lineupCurrentValidator = v.object({ + type: v.literal("lineup"), + revision: v.number(), + value: lineupPayloadValidator, +}); + +export const currentOpSnapshotValidator = v.union( + strategyCurrentValidator, + pageCurrentValidator, + pageContentCurrentValidator, + elementCurrentValidator, + lineupCurrentValidator, +); + +export const operationResultValidator = v.union( + v.object({ + opId: v.string(), + status: v.literal("applied"), + appliedRevision: v.number(), + }), + v.object({ + opId: v.string(), + status: v.literal("noop"), + currentRevision: v.optional(v.number()), + }), + v.object({ + opId: v.string(), + status: v.literal("rejected"), + reason: opRejectionReasonValidator, + current: v.optional(currentOpSnapshotValidator), + }), + v.object({ + opId: v.string(), + status: v.literal("failed"), + code: v.string(), + rawCode: v.string(), + message: v.string(), + }), +); + +export const applyBatchResultValidator = v.object({ + strategyPublicId: v.string(), + results: v.array(operationResultValidator), +}); diff --git a/convex/lib/opTypes.typecheck.ts b/convex/lib/opTypes.typecheck.ts new file mode 100644 index 00000000..ea4e2748 --- /dev/null +++ b/convex/lib/opTypes.typecheck.ts @@ -0,0 +1,19 @@ +import type { StrategyOp } from "./opTypes"; + +const validPageDelete: StrategyOp = { + opId: "page-delete", + type: "page.delete", + pagePublicId: "page-a", + expectedStrategyRevision: 1, +}; + +const illegalEntityActionPair: StrategyOp = { + opId: "illegal-page-delete", + type: "page.delete", + // @ts-expect-error An element id cannot be paired with a page discriminator. + elementPublicId: "element-a", + expectedStrategyRevision: 1, +}; + +void validPageDelete; +void illegalEntityActionPair; diff --git a/convex/lib/payloadValidators.ts b/convex/lib/payloadValidators.ts new file mode 100644 index 00000000..17f0b112 --- /dev/null +++ b/convex/lib/payloadValidators.ts @@ -0,0 +1,155 @@ +import { v, type GenericValidator } from "convex/values"; + +const jsonPrimitiveValidator: GenericValidator = v.union( + v.null(), + v.boolean(), + v.number(), + v.string(), +); + +const jsonValueValidator1: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonPrimitiveValidator), + v.record(v.string(), jsonPrimitiveValidator), +); +const jsonValueValidator2: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonValueValidator1), + v.record(v.string(), jsonValueValidator1), +); +const jsonValueValidator3: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonValueValidator2), + v.record(v.string(), jsonValueValidator2), +); +const jsonValueValidator4: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonValueValidator3), + v.record(v.string(), jsonValueValidator3), +); +const jsonValueValidator5: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonValueValidator4), + v.record(v.string(), jsonValueValidator4), +); +const jsonValueValidator6: GenericValidator = v.union( + jsonPrimitiveValidator, + v.array(jsonValueValidator5), + v.record(v.string(), jsonValueValidator5), +); + +export const cloudJsonValueValidator = jsonValueValidator6; +export const cloudJsonObjectValidator = v.record( + v.string(), + cloudJsonValueValidator, +); + +export const strategySettingsValidator = v.object({ + agentSize: v.number(), + abilitySize: v.number(), + useNeutralTeamColors: v.boolean(), +}); + +export const mapThemePaletteValidator = v.object({ + base: v.string(), + detail: v.string(), + highlight: v.string(), +}); + +export const strategyPatchPayloadValidator = v.object({ + name: v.optional(v.string()), + mapData: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + clearThemeProfileId: v.optional(v.boolean()), + themeOverridePalette: v.optional(mapThemePaletteValidator), + clearThemeOverridePalette: v.optional(v.boolean()), +}); + +export const pagePayloadValidator = v.object({ + name: v.optional(v.string()), + isAutoNamed: v.optional(v.boolean()), + settings: v.optional(strategySettingsValidator), + isAttack: v.optional(v.boolean()), +}); + +export const elementPayloadKindValidator = v.union( + v.literal("agent"), + v.literal("ability"), + v.literal("drawing"), + v.literal("text"), + v.literal("image"), + v.literal("utility"), +); + +export const elementPayloadValidator = v.union( + v.object({ + kind: v.literal("agent"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("ability"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("drawing"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("text"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("image"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("utility"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), +); + +// A lineup group is one row: the lineups on one page joined through shared +// spots, with each spot stored once. data holds the group's id (the row's +// key) and its origins (each placing an agent), landings (each placing an +// ability) and links (one per lineup: which origin to which landing, and its +// name, video, notes and images). See assertLineupPayload in ops.ts for the +// rules a row must meet. +export const lineupPayloadKindValidator = v.literal("lineups"); +export const LINEUPS_PAYLOAD_VERSION = 1; + +export const lineupPayloadValidator = v.object({ + kind: v.literal("lineups"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, +}); + +// What a lineup op may carry as an argument: a lineup group, or a row of the +// graph clients on protocol 4 wrote. Convex checks arguments before +// the handler runs, so refusing the old kinds here would answer an old +// client with a validation error instead of CLIENT_UPGRADE_REQUIRED from the +// protocol gate. The handler refuses them (see assertLineupPayload in +// ops.ts) and storage takes only lineup groups. +export const lineupOpPayloadValidator = v.union( + lineupPayloadValidator, + v.object({ + kind: v.literal("lineupOrigin"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("lineupLanding"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), + v.object({ + kind: v.literal("lineupLink"), + payloadVersion: v.number(), + data: cloudJsonObjectValidator, + }), +); diff --git a/convex/lib/profile.ts b/convex/lib/profile.ts new file mode 100644 index 00000000..68a9c542 --- /dev/null +++ b/convex/lib/profile.ts @@ -0,0 +1,65 @@ +// Who a signed-in person is, as their teammates should see them. Users sign +// in with Discord through Supabase, which keeps the Discord profile in the +// `user_metadata` claim rather than the standard name/picture claims. +// +// Convex flattens nested JWT claims into dotted keys, so the Discord display +// name arrives as identity["user_metadata.custom_claims.global_name"], not as +// an object (https://docs.convex.dev/auth/advanced/custom-jwt). Reading +// `identity.user_metadata` finds nothing. + +/** Stored when a sign-in carries no name at all. Never shown as a name. */ +export const UNKNOWN_DISPLAY_NAME = "Discord user"; + +export function profileFromIdentity( + identity: Record | null, +): { name: string | null; avatar: string | null } { + const text = (...path: string[]) => { + const value = claimAt(identity, path); + return typeof value === "string" && value.trim() !== "" + ? value.trim() + : null; + }; + return { + name: + text("user_metadata", "custom_claims", "global_name") ?? + text("user_metadata", "full_name") ?? + text("user_metadata", "name") ?? + text("user_metadata", "user_name") ?? + text("name") ?? + text("nickname") ?? + // Email sign-ins have no Discord name; the part before the @ is what a + // teammate would recognize. + emailName(text("email")), + avatar: + text("user_metadata", "avatar_url") ?? + text("user_metadata", "picture") ?? + text("pictureUrl"), + }; +} + +/** + * The claim at [path], whether Convex flattened it ("a.b.c"), left part of it + * nested ("a.b" -> {c}), or kept it whole ({a: {b: {c}}}). + */ +function claimAt( + claims: Record | null, + path: string[], +): unknown { + if (claims === null || path.length === 0) return claims; + for (let split = path.length; split >= 1; split--) { + const key = path.slice(0, split).join("."); + if (!(key in claims)) continue; + const value = claims[key]; + if (split === path.length) return value; + if (typeof value === "object" && value !== null) { + const found = claimAt(value as Record, path.slice(split)); + if (found !== undefined) return found; + } + } + return undefined; +} + +function emailName(email: string | null): string | null { + const local = email?.split("@")[0]?.trim(); + return local ? local : null; +} diff --git a/convex/lib/publicValidators.ts b/convex/lib/publicValidators.ts new file mode 100644 index 00000000..75624ca7 --- /dev/null +++ b/convex/lib/publicValidators.ts @@ -0,0 +1,205 @@ +import { v } from "convex/values"; +import { + elementPayloadKindValidator, + elementPayloadValidator, + lineupPayloadValidator, + mapThemePaletteValidator, + strategySettingsValidator, +} from "./payloadValidators"; + +export const accessRoleValidator = v.union( + v.literal("owner"), + v.literal("editor"), + v.literal("viewer"), +); + +export const collaboratorRoleValidator = v.union( + v.literal("editor"), + v.literal("viewer"), +); + +export const okResultValidator = v.object({ ok: v.literal(true) }); + +export const createResultValidator = v.union( + okResultValidator, + v.object({ ok: v.literal(true), reused: v.literal(true) }), +); + +export const revisionResultValidator = v.union( + v.object({ ok: v.literal(true), revision: v.number() }), + v.object({ + ok: v.literal(true), + reused: v.literal(true), + revision: v.number(), + }), +); + +export const strategyHeaderValidator = v.object({ + publicId: v.string(), + name: v.string(), + mapData: v.string(), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + themeProfileId: v.union(v.string(), v.null()), + themeOverridePalette: v.union(mapThemePaletteValidator, v.null()), + role: accessRoleValidator, +}); + +export const strategySummaryValidator = v.object({ + publicId: v.string(), + name: v.string(), + mapData: v.string(), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + role: accessRoleValidator, + attackLabel: v.union( + v.literal("Unknown"), + v.literal("Mixed"), + v.literal("Attack"), + v.literal("Defend"), + ), + folderPublicId: v.union(v.string(), v.null()), + themeProfileId: v.union(v.string(), v.null()), + themeOverridePalette: v.union(mapThemePaletteValidator, v.null()), +}); + +export const folderSummaryValidator = v.object({ + publicId: v.string(), + name: v.string(), + iconId: v.union(v.number(), v.null()), + iconCodePoint: v.union(v.number(), v.null()), + iconFontFamily: v.union(v.string(), v.null()), + iconFontPackage: v.union(v.string(), v.null()), + color: v.union(v.string(), v.null()), + customColorValue: v.union(v.number(), v.null()), + parentFolderPublicId: v.union(v.string(), v.null()), + createdAt: v.number(), + updatedAt: v.number(), + role: accessRoleValidator, + // What the folder holds, including every subfolder: how many strategies, + // the two most-used maps (wire names, most used first), and the agents in + // play ordered by how many strategies use them. + strategyCount: v.number(), + mapPeeks: v.array(v.string()), + agentTypes: v.array(v.string()), +}); + +/// A page in the trash, as Recently deleted lists it. [deletedByName] is null +/// when who deleted it is not known. +export const trashedPageValidator = v.object({ + publicId: v.string(), + name: v.string(), + deletedAt: v.number(), + restorableUntil: v.number(), + deletedByName: v.union(v.string(), v.null()), + deletedByYou: v.boolean(), +}); + +export const pageDescriptorValidator = v.object({ + publicId: v.string(), + strategyPublicId: v.string(), + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + sortIndex: v.number(), + isAttack: v.boolean(), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), +}); + +export const pageContentValidator = v.object({ + settings: v.union(strategySettingsValidator, v.null()), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), +}); + +export const fullPageValidator = v.object({ + publicId: v.string(), + strategyPublicId: v.string(), + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + sortIndex: v.number(), + isAttack: v.boolean(), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + settings: v.union(strategySettingsValidator, v.null()), + contentRevision: v.number(), + contentCreatedAt: v.number(), + contentUpdatedAt: v.number(), +}); + +export const elementValidator = v.object({ + publicId: v.string(), + strategyPublicId: v.string(), + pagePublicId: v.string(), + elementType: elementPayloadKindValidator, + payload: elementPayloadValidator, + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), +}); + +export const lineupValidator = v.object({ + publicId: v.string(), + strategyPublicId: v.string(), + pagePublicId: v.string(), + payload: lineupPayloadValidator, + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), +}); + +export const imageProviderValidator = v.union( + v.literal("convex"), + v.literal("r2"), +); + +export const imageUploadStatusValidator = v.union( + v.literal("pending"), + v.literal("active"), + v.literal("failed"), + v.literal("deleted"), +); + +export const imageAssetValidator = v.object({ + publicId: v.string(), + provider: imageProviderValidator, + uploadStatus: imageUploadStatusValidator, + fileExtension: v.string(), + mimeType: v.union(v.string(), v.null()), + width: v.union(v.number(), v.null()), + height: v.union(v.number(), v.null()), + byteSize: v.union(v.number(), v.null()), + uploadedAt: v.union(v.number(), v.null()), + url: v.union(v.string(), v.null()), + legacyStoragePath: v.union(v.string(), v.null()), +}); + +export const strategyShellValidator = v.object({ + header: strategyHeaderValidator, + pages: v.array(pageDescriptorValidator), +}); + +export const pageSnapshotValidator = v.object({ + page: pageDescriptorValidator, + content: pageContentValidator, + elements: v.array(elementValidator), + lineups: v.array(lineupValidator), + assets: v.array(imageAssetValidator), +}); + +export const fullStrategySnapshotValidator = v.object({ + header: strategyHeaderValidator, + pages: v.array(fullPageValidator), + elements: v.array(elementValidator), + lineups: v.array(lineupValidator), + assets: v.array(imageAssetValidator), +}); diff --git a/convex/lib/r2.ts b/convex/lib/r2.ts new file mode 100644 index 00000000..8867a3bd --- /dev/null +++ b/convex/lib/r2.ts @@ -0,0 +1,419 @@ +declare const process: { env: Record }; +import { internalError, invalidPayloadError } from "./errors"; + +export type R2Config = { + accountId: string; + bucket: string; + accessKeyId: string; + secretAccessKey: string; + endpoint: string; + publicBaseUrl: string; + uploadUrlExpiresSeconds: number; + maxImageBytes: number; +}; + +export type R2ObjectMetadata = { + byteSize: number | null; + mimeType: string | null; + etag: string | null; +}; + +const defaultUploadUrlExpiresSeconds = 15 * 60; +const defaultMaxImageBytes = 15 * 1024 * 1024; +const r2Region = "auto"; +const r2Service = "s3"; +const unsignedPayload = "UNSIGNED-PAYLOAD"; +const emptyPayloadHash = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +let cachedR2PublicBaseUrl: string | null = null; + +const mimeByExtension: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +}; + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value.trim() === "") { + throw internalError( + `Missing Cloudflare R2 environment variable ${name}. Set R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_PUBLIC_BASE_URL on the Convex deployment.`, + ); + } + return value.trim(); +} + +function optionalPositiveIntEnv( + name: string, + fallback: number, + options: { min?: number; max?: number } = {}, +): number { + const raw = process.env[name]; + if (raw === undefined || raw.trim() === "") { + return fallback; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw invalidPayloadError(`${name} must be a positive integer.`); + } + if (options.min !== undefined && parsed < options.min) { + throw invalidPayloadError(`${name} must be at least ${options.min}.`); + } + if (options.max !== undefined && parsed > options.max) { + throw invalidPayloadError(`${name} must be at most ${options.max}.`); + } + return parsed; +} + +export function getUploadUrlExpiresSeconds(): number { + return optionalPositiveIntEnv( + "R2_UPLOAD_URL_EXPIRES_SECONDS", + defaultUploadUrlExpiresSeconds, + { min: 1, max: 604800 }, + ); +} + +export function getR2Config(): R2Config { + const accountId = requiredEnv("R2_ACCOUNT_ID"); + const bucket = requiredEnv("R2_BUCKET"); + const endpoint = + process.env.R2_S3_ENDPOINT?.trim() || + `https://${accountId}.r2.cloudflarestorage.com`; + + return { + accountId, + bucket, + endpoint: endpoint.replace(/\/+$/, ""), + accessKeyId: requiredEnv("R2_ACCESS_KEY_ID"), + secretAccessKey: requiredEnv("R2_SECRET_ACCESS_KEY"), + publicBaseUrl: getR2PublicBaseUrl(), + uploadUrlExpiresSeconds: getUploadUrlExpiresSeconds(), + maxImageBytes: optionalPositiveIntEnv( + "R2_MAX_IMAGE_BYTES", + defaultMaxImageBytes, + ), + }; +} + +export function getR2PublicBaseUrl(): string { + cachedR2PublicBaseUrl ??= requiredEnv("R2_PUBLIC_BASE_URL").replace( + /\/+$/, + "", + ); + return cachedR2PublicBaseUrl; +} + +export function normalizeImageExtension(extension: string | undefined): string { + if (extension === undefined || extension.trim() === "") { + return ""; + } + const trimmed = extension.trim().toLowerCase(); + return trimmed.startsWith(".") ? trimmed : `.${trimmed}`; +} + +export function expectedMimeTypeForExtension( + extension: string | undefined, +): string | null { + const normalized = normalizeImageExtension(extension); + return mimeByExtension[normalized] ?? null; +} + +export function validateImageUploadMetadata(args: { + fileExtension?: string; + mimeType?: string; + byteSize?: number; + maxImageBytes: number; +}): { fileExtension: string; mimeType: string } { + const fileExtension = normalizeImageExtension(args.fileExtension); + const expectedMimeType = expectedMimeTypeForExtension(fileExtension); + if (expectedMimeType === null) { + throw invalidPayloadError( + `Unsupported image extension "${args.fileExtension ?? ""}". Supported extensions: ${Object.keys( + mimeByExtension, + ).join(", ")}.`, + ); + } + + const mimeType = (args.mimeType ?? expectedMimeType).toLowerCase(); + if (mimeType !== expectedMimeType) { + throw invalidPayloadError( + `Image MIME type ${mimeType} does not match ${fileExtension} (${expectedMimeType}).`, + ); + } + + if ( + args.byteSize !== undefined && + (!Number.isFinite(args.byteSize) || + args.byteSize <= 0 || + args.byteSize > args.maxImageBytes) + ) { + throw invalidPayloadError( + `Image is too large. Maximum allowed size is ${args.maxImageBytes} bytes.`, + ); + } + + return { fileExtension, mimeType }; +} + +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace(/[!'()*]/g, (char) => + `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ); +} + +function encodePath(path: string): string { + return path.split("/").map(encodeRfc3986).join("/"); +} + +function safeObjectKeySegment(value: string): string { + const sanitized = value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 128); + return sanitized.length === 0 ? "asset" : sanitized; +} + +function randomHex(byteLength: number): string { + const cryptoApi = globalThis.crypto; + if (cryptoApi === undefined) { + throw internalError("Web Crypto is unavailable; cannot create R2 object key."); + } + const bytes = new Uint8Array(byteLength); + cryptoApi.getRandomValues(bytes); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} + +export function createR2ObjectKey(args: { + strategyPublicId: string; + assetPublicId: string; + fileExtension: string; +}): string { + const strategy = safeObjectKeySegment(args.strategyPublicId); + const asset = safeObjectKeySegment(args.assetPublicId); + return `strategies/${strategy}/images/${asset}/${Date.now()}-${randomHex( + 16, + )}${args.fileExtension}`; +} + +export function publicR2UrlForObjectKey(objectKey: string): string { + return `${getR2PublicBaseUrl()}/${encodePath(objectKey)}`; +} + +function toAmzDate(date: Date): { amzDate: string; dateStamp: string } { + const iso = date.toISOString().replace(/[:-]|\.\d{3}/g, ""); + return { + amzDate: iso, + dateStamp: iso.slice(0, 8), + }; +} + +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer), (byte) => + byte.toString(16).padStart(2, "0"), + ).join(""); +} + +function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + return buffer; +} + +function utf8(value: string): ArrayBuffer { + return toArrayBuffer(new TextEncoder().encode(value)); +} + +async function hmacSha256(key: ArrayBuffer, data: string): Promise { + const cryptoKey = await globalThis.crypto.subtle.importKey( + "raw", + key, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + return await globalThis.crypto.subtle.sign( + "HMAC", + cryptoKey, + utf8(data), + ); +} + +async function signingKey( + secretAccessKey: string, + dateStamp: string, +): Promise { + const dateKey = await hmacSha256( + utf8(`AWS4${secretAccessKey}`), + dateStamp, + ); + const regionKey = await hmacSha256(dateKey, r2Region); + const serviceKey = await hmacSha256(regionKey, r2Service); + return await hmacSha256(serviceKey, "aws4_request"); +} + +function canonicalQueryString(params: Array<[string, string]>): string { + return [...params] + .sort(([leftName, leftValue], [rightName, rightValue]) => { + if (leftName === rightName) { + return leftValue.localeCompare(rightValue); + } + return leftName.localeCompare(rightName); + }) + .map(([name, value]) => `${encodeRfc3986(name)}=${encodeRfc3986(value)}`) + .join("&"); +} + +function objectUrl(config: R2Config, objectKey: string): URL { + return new URL( + `/${encodeRfc3986(config.bucket)}/${encodePath(objectKey)}`, + config.endpoint, + ); +} + +async function signatureForCanonicalRequest(args: { + secretAccessKey: string; + dateStamp: string; + amzDate: string; + canonicalRequest: string; +}): Promise { + const canonicalRequestHash = toHex( + await globalThis.crypto.subtle.digest( + "SHA-256", + utf8(args.canonicalRequest), + ), + ); + const credentialScope = `${args.dateStamp}/${r2Region}/${r2Service}/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + args.amzDate, + credentialScope, + canonicalRequestHash, + ].join("\n"); + return toHex( + await hmacSha256( + await signingKey(args.secretAccessKey, args.dateStamp), + stringToSign, + ), + ); +} + +export async function presignR2PutUrl(args: { + config: R2Config; + objectKey: string; + mimeType: string; + now?: Date; +}): Promise<{ uploadUrl: string; expiresAt: number; requiredHeaders: Record }> { + const now = args.now ?? new Date(); + const { amzDate, dateStamp } = toAmzDate(now); + const url = objectUrl(args.config, args.objectKey); + const credentialScope = `${dateStamp}/${r2Region}/${r2Service}/aws4_request`; + const signedHeaders = "content-type;host"; + const queryParams: Array<[string, string]> = [ + ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"], + ["X-Amz-Content-Sha256", unsignedPayload], + ["X-Amz-Credential", `${args.config.accessKeyId}/${credentialScope}`], + ["X-Amz-Date", amzDate], + ["X-Amz-Expires", String(args.config.uploadUrlExpiresSeconds)], + ["X-Amz-SignedHeaders", signedHeaders], + ]; + const canonicalRequest = [ + "PUT", + url.pathname, + canonicalQueryString(queryParams), + `content-type:${args.mimeType}\nhost:${url.host}\n`, + signedHeaders, + unsignedPayload, + ].join("\n"); + const signature = await signatureForCanonicalRequest({ + secretAccessKey: args.config.secretAccessKey, + dateStamp, + amzDate, + canonicalRequest, + }); + + url.search = canonicalQueryString([ + ...queryParams, + ["X-Amz-Signature", signature], + ]); + return { + uploadUrl: url.toString(), + expiresAt: now.getTime() + args.config.uploadUrlExpiresSeconds * 1000, + requiredHeaders: { + "Content-Type": args.mimeType, + }, + }; +} + +async function signedR2Fetch(args: { + config: R2Config; + method: "HEAD" | "DELETE"; + objectKey: string; +}): Promise { + const now = new Date(); + const { amzDate, dateStamp } = toAmzDate(now); + const url = objectUrl(args.config, args.objectKey); + const signedHeaders = "host;x-amz-content-sha256;x-amz-date"; + const credentialScope = `${dateStamp}/${r2Region}/${r2Service}/aws4_request`; + const canonicalRequest = [ + args.method, + url.pathname, + "", + `host:${url.host}\nx-amz-content-sha256:${emptyPayloadHash}\nx-amz-date:${amzDate}\n`, + signedHeaders, + emptyPayloadHash, + ].join("\n"); + const signature = await signatureForCanonicalRequest({ + secretAccessKey: args.config.secretAccessKey, + dateStamp, + amzDate, + canonicalRequest, + }); + const authorization = [ + `AWS4-HMAC-SHA256 Credential=${args.config.accessKeyId}/${credentialScope}`, + `SignedHeaders=${signedHeaders}`, + `Signature=${signature}`, + ].join(", "); + + return await fetch(url, { + method: args.method, + headers: { + Authorization: authorization, + "x-amz-content-sha256": emptyPayloadHash, + "x-amz-date": amzDate, + }, + }); +} + +export async function headR2Object( + config: R2Config, + objectKey: string, +): Promise { + const response = await signedR2Fetch({ config, method: "HEAD", objectKey }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw internalError(`R2 HEAD failed with ${response.status}.`); + } + const byteSize = Number.parseInt(response.headers.get("content-length") ?? "", 10); + return { + byteSize: Number.isFinite(byteSize) ? byteSize : null, + mimeType: response.headers.get("content-type"), + etag: response.headers.get("etag"), + }; +} + +export async function deleteR2Object( + config: R2Config, + objectKey: string, +): Promise { + const response = await signedR2Fetch({ config, method: "DELETE", objectKey }); + if (response.status === 404) { + return; + } + if (!response.ok) { + throw internalError(`R2 DELETE failed with ${response.status}.`); + } +} diff --git a/convex/lib/snapshotSerialization.ts b/convex/lib/snapshotSerialization.ts new file mode 100644 index 00000000..3946cbde --- /dev/null +++ b/convex/lib/snapshotSerialization.ts @@ -0,0 +1,83 @@ +import type { Doc } from "../_generated/dataModel"; + +export function serializeStrategyHeader( + strategy: Doc<"strategies">, + role: "owner" | "editor" | "viewer", +) { + return { + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + revision: strategy.revision, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + role, + }; +} + +export function serializePageDescriptor( + strategyPublicId: string, + page: Doc<"pages">, +) { + return { + publicId: page.publicId, + strategyPublicId, + name: page.name, + ...(page.isAutoNamed === undefined + ? {} + : { isAutoNamed: page.isAutoNamed }), + sortIndex: page.sortIndex, + isAttack: page.isAttack, + revision: page.revision, + createdAt: page.createdAt, + updatedAt: page.updatedAt, + }; +} + +export function serializePageContent(pageContent: Doc<"pageContents">) { + return { + settings: pageContent.settings ?? null, + revision: pageContent.revision, + createdAt: pageContent.createdAt, + updatedAt: pageContent.updatedAt, + }; +} + +export function serializeElement( + strategyPublicId: string, + pagePublicId: string, + element: Doc<"elements">, +) { + return { + publicId: element.publicId, + strategyPublicId, + pagePublicId, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + createdAt: element.createdAt, + updatedAt: element.updatedAt, + }; +} + +export function serializeLineup( + strategyPublicId: string, + pagePublicId: string, + lineup: Doc<"lineups">, +) { + return { + publicId: lineup.publicId, + strategyPublicId, + pagePublicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + createdAt: lineup.createdAt, + updatedAt: lineup.updatedAt, + }; +} diff --git a/convex/lib/strategyAgentSummary.ts b/convex/lib/strategyAgentSummary.ts new file mode 100644 index 00000000..097f1245 --- /dev/null +++ b/convex/lib/strategyAgentSummary.ts @@ -0,0 +1,206 @@ +import type { Doc, Id } from "../_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "../_generated/server"; +import type { WithoutSystemFields } from "convex/server"; + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +/// Reads the agent type the client stores as the enum name under `type`, as +/// an agent element's data and a lineup origin's agent both do. +function agentTypeOf(data: unknown): string | null { + const type = asRecord(data)?.type; + return typeof type === "string" && type.length > 0 ? type : null; +} + +/// The agents a lineup group starts from: each origin's id and agent type, +/// skipping an origin whose agent names no type. +export function lineupAgentsOf( + payload: Doc<"lineups">["payload"], +): { originId: string; agentType: string }[] { + const origins = payload.data.origins; + if (!Array.isArray(origins)) return []; + const agents: { originId: string; agentType: string }[] = []; + for (const origin of origins) { + const record = asRecord(origin); + const agentType = agentTypeOf(record?.agent); + if (record === null || typeof record.id !== "string" || agentType === null) { + continue; + } + agents.push({ originId: record.id, agentType }); + } + return agents; +} + +type LineupAgent = Pick< + Doc<"lineupAgents">, + "pageId" | "originId" | "agentType" +>; + +/// Brings the lineupAgents rows of one lineup group in step with it: one +/// row per origin while the group is live, none otherwise. Call after every +/// write of a lineup row, with null once the row itself is gone. +export async function syncLineupAgents( + ctx: MutationCtx, + lineupId: Id<"lineups">, + lineup: Pick< + Doc<"lineups">, + "strategyId" | "pageId" | "deleted" | "payload" + > | null, +): Promise { + const existing = await ctx.db + .query("lineupAgents") + .withIndex("by_lineupId", (q) => q.eq("lineupId", lineupId)) + .collect(); + // Keyed by origin id, which is unique within a group. + const wanted = new Map>>(); + if (lineup !== null && !lineup.deleted) { + for (const agent of lineupAgentsOf(lineup.payload)) { + wanted.set(agent.originId, { + strategyId: lineup.strategyId, + pageId: lineup.pageId, + lineupId, + ...agent, + }); + } + } + for (const row of existing) { + const want = wanted.get(row.originId); + if (want === undefined) { + // An origin the group no longer holds, or a second row for one. + await ctx.db.delete(row._id); + continue; + } + wanted.delete(row.originId); + if ( + row.strategyId !== want.strategyId || + row.pageId !== want.pageId || + row.agentType !== want.agentType + ) { + await ctx.db.patch(row._id, want); + } + } + for (const row of wanted.values()) { + await ctx.db.insert("lineupAgents", row); + } +} + +/// Recomputes which agents a strategy uses, from its live agent elements and +/// the origins of its live lineups on pages not in the trash, and stores the +/// answer in its own row. Content ops never touch the strategy row itself; +/// the summary is derived data that the folder tree reads without scanning +/// elements. +export async function refreshStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + // Agent elements are small. Lineups are read through their lineupAgents + // rows, never the lineup rows themselves: those carry image lists and + // could take a strategy's reads past a transaction's limits. + const agents = await ctx.db + .query("elements") + .withIndex("by_strategyId_and_elementType", (q) => + q.eq("strategyId", strategyId).eq("elementType", "agent"), + ) + .collect(); + const lineupAgents = await ctx.db + .query("lineupAgents") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .collect(); + const trashedPageIds = new Set( + ( + await ctx.db + .query("pages") + .withIndex("by_strategyId_and_deletedAt", (q) => + q.eq("strategyId", strategyId).gt("deletedAt", undefined), + ) + .collect() + ).map((page) => page._id), + ); + const onLivePage = (row: { pageId: Id<"pages"> }) => + !trashedPageIds.has(row.pageId); + await storeStrategyAgentSummary( + ctx, + strategyId, + agentTypesOf(agents.filter(onLivePage), lineupAgents.filter(onLivePage)), + ); +} + +/// The agents that live content uses, most used first. Takes any elements +/// and the agents of live lineup groups; live agent elements count, and +/// each lineup origin once per page, however many lineups share it. +export function agentTypesOf( + elements: Pick, "deleted" | "elementType" | "payload">[], + lineupAgents: LineupAgent[], +): string[] { + const counts = new Map(); + const bump = (type: string | null) => { + if (type === null) return; + counts.set(type, (counts.get(type) ?? 0) + 1); + }; + for (const element of elements) { + if (element.deleted || element.elementType !== "agent") continue; + bump(agentTypeOf(element.payload.data)); + } + const countedOrigins = new Set(); + for (const { pageId, originId, agentType } of lineupAgents) { + const originKey = `${pageId}:${originId}`; + if (countedOrigins.has(originKey)) continue; + countedOrigins.add(originKey); + bump(agentType); + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([type]) => type); +} + +/// Stores [agentTypes] as the strategy's summary, writing only on change. +export async function storeStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, + agentTypes: string[], +): Promise { + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + const now = Date.now(); + if (existing === null) { + if (agentTypes.length === 0) return; + await ctx.db.insert("strategyAgentSummaries", { + strategyId, + agentTypes, + updatedAt: now, + }); + return; + } + const unchanged = + existing.agentTypes.length === agentTypes.length && + existing.agentTypes.every((type, index) => type === agentTypes[index]); + if (unchanged) return; + await ctx.db.patch(existing._id, { agentTypes, updatedAt: now }); +} + +export async function deleteStrategyAgentSummary( + ctx: MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const existing = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + if (existing !== null) await ctx.db.delete(existing._id); +} + +export async function readStrategyAgentTypes( + ctx: QueryCtx | MutationCtx, + strategyId: Id<"strategies">, +): Promise { + const summary = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + return summary?.agentTypes ?? []; +} diff --git a/convex/lineups.test.ts b/convex/lineups.test.ts new file mode 100644 index 00000000..b37008fe --- /dev/null +++ b/convex/lineups.test.ts @@ -0,0 +1,1643 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { + insertLineup, + lineupsPayload, + oneLineupPayload, + type TestLineup, + type TestLineupGroup, +} from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const getPageSnapshot = makeFunctionReference<"query">("page:getSnapshot"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const listForStrategy = makeFunctionReference<"query">( + "lineups:listForStrategy", +); +const listReferencedAssetIds = makeFunctionReference<"query">( + "images:listReferencedAssetIds", +); +const duplicateStrategy = makeFunctionReference<"mutation">( + "strategies:duplicate", +); +const restorePage = makeFunctionReference<"mutation">("pages:restore"); +const purgeOldTombstones = makeFunctionReference<"mutation">( + "maintenance:purgeOldTombstones", +); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; +type Result = Record; +type LineupRow = { + publicId: string; + payload: { kind: string; data: Record }; + revision: number; + deleted: boolean; +}; + +const strategyPublicId = "lineups-strategy"; +const pagePublicId = "lineups-page"; + +function identity(subject: string) { + return { + issuer: "https://lineups.test", + subject, + tokenIdentifier: `lineups|${subject}`, + name: subject, + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; + editor: Harness; + viewer: Harness; +}> { + const t = convexTest(schema, modules); + const owner = t.withIdentity(identity("owner")); + const editor = t.withIdentity(identity("editor")); + const viewer = t.withIdentity(identity("viewer")); + for (const user of [owner, editor, viewer]) { + await user.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + } + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "Lineups", + mapData: "ascent", + initialPagePublicId: pagePublicId, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + for (const [user, role] of [ + [editor, "editor"], + [viewer, "viewer"], + ] as const) { + await owner.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: `${role}-token`, + role, + }); + await user.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: `${role}-token`, + }); + } + return { t, owner, editor, viewer }; +} + +/** A group row holding one lineup, as a test sends it: its key and payload. */ +function lineup(id: string, details: TestLineup = {}) { + return { key: id, payload: oneLineupPayload(id, details) }; +} + +/** A lineup group row as a test sends it: its key and its payload. */ +function group(id: string, lineups: TestLineupGroup) { + return { key: id, payload: lineupsPayload(id, lineups) }; +} + +type Row = { key: string; payload: { kind: string; data: object } }; + +function addOp(row: Row, sortIndex: number, page = pagePublicId) { + return { + opId: `add-${row.key}`, + type: "lineup.add", + lineupPublicId: row.key, + pagePublicId: page, + payload: row.payload, + sortIndex, + }; +} + +function patchOp(opId: string, row: Row, expectedLineupRevision: number) { + return { + opId, + type: "lineup.patch", + lineupPublicId: row.key, + pagePublicId, + payload: row.payload, + expectedLineupRevision, + }; +} + +function deleteOp(opId: string, key: string, expectedLineupRevision: number) { + return { + opId, + type: "lineup.delete", + lineupPublicId: key, + pagePublicId, + expectedLineupRevision, + }; +} + +async function apply( + user: Harness, + clientId: string, + ops: Array>, + strategy = strategyPublicId, +): Promise { + const response = (await user.mutation(applyBatch, { + strategyPublicId: strategy, + clientId, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops, + })) as { results: Result[] }; + return response.results; +} + +async function pageLineups( + user: Harness, + strategy = strategyPublicId, + page = pagePublicId, +): Promise { + const snapshot = (await user.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: strategy, + pagePublicId: page, + })) as { lineups: LineupRow[] }; + return snapshot.lineups; +} + +function byKey(rows: LineupRow[]): Map { + return new Map(rows.map((row) => [row.publicId, row])); +} + +function liveKeys(rows: LineupRow[]): string[] { + return rows.filter((row) => !row.deleted).map((row) => row.publicId); +} + +async function addSecondPage(owner: Harness, publicId: string) { + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: publicId, + name: "Page 2", + sortIndex: 1, + isAttack: false, + }); +} + +async function agentSummary( + t: RootHarness, + strategy = strategyPublicId, +): Promise { + return await t.run(async (ctx) => { + const row = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategy)) + .unique(); + const summary = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", row!._id)) + .unique(); + return summary?.agentTypes ?? []; + }); +} + +type LineupAgentRow = { + lineup: string; + page: string; + originId: string; + agentType: string; +}; + +/// A strategy's lineupAgents rows, each named by its group's and page's +/// public ids, ordered by group and origin. Fails on a row that names a +/// group gone or out of step with it (another strategy or page). +async function lineupAgentRows( + t: RootHarness, + strategy = strategyPublicId, +): Promise { + return await t.run(async (ctx) => { + const strategyRow = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategy)) + .unique(); + const rows = await ctx.db + .query("lineupAgents") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyRow!._id)) + .collect(); + const described: LineupAgentRow[] = []; + for (const row of rows) { + const lineup = await ctx.db.get(row.lineupId); + const page = await ctx.db.get(row.pageId); + if ( + lineup === null || + page === null || + lineup.strategyId !== row.strategyId || + lineup.pageId !== row.pageId + ) { + throw new Error(`lineupAgents row ${row._id} is out of step`); + } + described.push({ + lineup: lineup.publicId, + page: page.publicId, + originId: row.originId, + agentType: row.agentType, + }); + } + return described.sort( + (a, b) => + a.lineup.localeCompare(b.lineup) || + a.originId.localeCompare(b.originId), + ); + }); +} + +async function strategyRevision(t: RootHarness): Promise { + return await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + return strategy!.revision; + }); +} + +beforeAll(() => { + // Page snapshots serialize asset URLs from the R2 public base. + process.env.R2_ACCOUNT_ID = "lineups-account"; + process.env.R2_BUCKET = "lineups-bucket"; + process.env.R2_ACCESS_KEY_ID = "lineups-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "lineups-secret"; + process.env.R2_PUBLIC_BASE_URL = "https://assets.lineups.test"; + process.env.R2_S3_ENDPOINT = "https://lineups.r2.test"; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +/// One group: two lineups from different origins into one shared landing +/// (fan-in), and a third from the first one's origin to another landing +/// (fan-out). A fresh copy each call, for a test to change. +function siteLineups(): TestLineupGroup { + return { + origins: [ + { id: "heaven", agentType: "brimstone", position: { dx: 120, dy: 340 } }, + { id: "mid", agentType: "viper", position: { dx: 200, dy: 100 } }, + ], + landings: [ + { id: "site", position: { dx: 400, dy: 220 } }, + { id: "hell", position: { dx: 500, dy: 300 } }, + ], + links: [ + { + id: "from-heaven", + originId: "heaven", + landingId: "site", + name: "From heaven", + youtubeLink: "https://youtu.be/heaven", + notes: "Jump throw", + }, + { id: "from-mid", originId: "mid", landingId: "site", name: "From mid" }, + { + id: "heaven-to-hell", + originId: "heaven", + landingId: "hell", + name: "Hell", + }, + ], + }; +} + +const siteKey = "site-group"; + +function siteGroup(edit: (lineups: TestLineupGroup) => void = () => {}) { + const lineups = siteLineups(); + edit(lineups); + return group(siteKey, lineups); +} + +describe("one row per lineup group", () => { + test("a group round-trips with its origins, landings and links intact", async () => { + const { owner } = await createHarness(); + const row = siteGroup(); + const results = await apply(owner, "owner-client", [addOp(row, 0)]); + expect(results[0]).toMatchObject({ status: "applied", appliedRevision: 1 }); + + const rows = await pageLineups(owner); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + publicId: siteKey, + revision: 1, + deleted: false, + }); + expect(rows[0]!.payload).toEqual(row.payload); + expect(rows[0]!.payload.kind).toBe("lineups"); + + const full = (await owner.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { lineups: LineupRow[] }; + expect(full.lineups.map((entry) => entry.payload)).toEqual([row.payload]); + + const listed = (await owner.query(listForStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as LineupRow[]; + expect(listed.map((entry) => entry.payload)).toEqual([row.payload]); + }); + + test("a group, its lineup and that lineup's landing may share one id", async () => { + const { owner } = await createHarness(); + const row = group("same-id", { + origins: [{ id: "origin" }], + landings: [{ id: "same-id" }], + links: [{ id: "same-id", originId: "origin", landingId: "same-id" }], + }); + const results = await apply(owner, "owner-client", [addOp(row, 0)]); + expect(results[0]).toMatchObject({ status: "applied" }); + expect((await pageLineups(owner))[0]!.payload).toEqual(row.payload); + }); + + test("moving a shared spot is one patch of its group, seen by every lineup in it", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [addOp(siteGroup(), 0)]); + const moved = { dx: 10, dy: 20 }; + const edit = siteGroup((lineups) => { + lineups.landings[0]!.position = moved; + }); + + const results = await apply(owner, "owner-client", [ + patchOp("move-site", edit, 1), + ]); + expect(results[0]).toMatchObject({ status: "applied", appliedRevision: 2 }); + const rows = await pageLineups(owner); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ revision: 2 }); + expect(rows[0]!.payload).toEqual(edit.payload); + const data = rows[0]!.payload.data as { + landings: Array<{ id: string; ability: { position: unknown } }>; + links: Array<{ landingId: string }>; + }; + // The spot is stored once, and both lineups into it name it. + expect(data.landings.filter((landing) => landing.id === "site")).toEqual([ + { + id: "site", + ability: { id: "ability-site", position: moved, lineUpID: "site" }, + }, + ]); + expect(data.links.map((link) => link.landingId)).toEqual([ + "site", + "site", + "hell", + ]); + }); + + test("teammates editing different lineups of one group from the same revision: the second is a conflict, not a silent overwrite", async () => { + const { owner, editor } = await createHarness(); + await apply(owner, "owner-client", [addOp(siteGroup(), 0)]); + const ownerEdit = siteGroup((lineups) => { + lineups.links[0]!.name = "Owner name"; + }); + const ownerResults = await apply(owner, "owner-client", [ + patchOp("owner-renames", ownerEdit, 1), + ]); + expect(ownerResults[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + + // The editor, still on revision 1, writes notes on another lineup of + // the same group. + const stale = await apply(editor, "editor-client", [ + patchOp( + "editor-notes", + siteGroup((lineups) => { + lineups.links[1]!.notes = "Editor notes"; + }), + 1, + ), + ]); + expect(stale[0]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { type: "lineup", revision: 2, value: ownerEdit.payload }, + }); + const row = byKey(await pageLineups(owner)).get(siteKey); + expect(row).toMatchObject({ revision: 2 }); + expect(row!.payload).toEqual(ownerEdit.payload); + }); + + test("a viewer reads lineup groups but cannot write them", async () => { + const { owner, viewer } = await createHarness(); + await apply(owner, "owner-client", [addOp(siteGroup(), 0)]); + await expect( + apply(viewer, "viewer-client", [addOp(lineup("viewer-lineup"), 1)]), + ).rejects.toThrow("Forbidden"); + await expect( + apply(viewer, "viewer-client", [ + patchOp( + "viewer-renames", + siteGroup((lineups) => { + lineups.links[0]!.name = "Viewer"; + }), + 1, + ), + ]), + ).rejects.toThrow("Forbidden"); + expect((await pageLineups(viewer)).map((row) => row.publicId)).toEqual([ + siteKey, + ]); + }); +}); + +describe("a lineup group row is checked whole", () => { + /// An add of the site group under [key] whose payload [edit] has changed. + function brokenAdd( + key: string, + edit: (payload: { + kind: string; + payloadVersion: number; + data: Record; + }) => void, + ) { + const payload = structuredClone(group(key, siteLineups()).payload) as { + kind: string; + payloadVersion: number; + data: Record; + }; + edit(payload); + return { ...addOp({ key, payload }, 0), opId: `add-${key}` }; + } + + test("each broken rule refuses the row with its code, and nothing is stored", async () => { + const { owner } = await createHarness(); + const cases: Array<{ + op: Record; + code: string; + }> = [ + { + op: brokenAdd("unknown-version", (payload) => { + payload.payloadVersion = 2; + }), + code: "INVALID_LINEUP_PAYLOAD_VERSION", + }, + { + op: brokenAdd("no-id", (payload) => { + delete payload.data.id; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: { ...brokenAdd("keyed", () => {}), lineupPublicId: "other-key" }, + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + ...(["origins", "landings", "links"] as const).flatMap((field) => [ + { + op: brokenAdd(`no-${field}`, (payload) => { + delete payload.data[field]; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd(`${field}-not-a-list`, (payload) => { + payload.data[field] = { [payload.data[field][0].id]: true }; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd(`${field}-entry-not-an-object`, (payload) => { + payload.data[field][0] = payload.data[field][0].id; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd(`${field}-entry-without-id`, (payload) => { + delete payload.data[field][0].id; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd(`${field}-entry-with-empty-id`, (payload) => { + payload.data[field][0].id = ""; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd(`${field}-repeat-an-id`, (payload) => { + payload.data[field][1].id = payload.data[field][0].id; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + ]), + { + op: brokenAdd("origin-without-agent", (payload) => { + delete payload.data.origins[0].agent; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("landing-without-ability", (payload) => { + delete payload.data.landings[0].ability; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("no-lineups", (payload) => { + payload.data.links = []; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("link-to-missing-origin", (payload) => { + payload.data.links[0].originId = "nowhere"; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("link-to-missing-landing", (payload) => { + payload.data.links[0].landingId = "nowhere"; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("link-without-origin", (payload) => { + delete payload.data.links[0].originId; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + op: brokenAdd("link-without-landing", (payload) => { + delete payload.data.links[0].landingId; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + { + // A link may not name a landing as its origin. + op: brokenAdd("link-ends-swapped", (payload) => { + payload.data.links[0].originId = "site"; + payload.data.links[0].landingId = "heaven"; + }), + code: "INVALID_LINEUP_PAYLOAD_DATA", + }, + ]; + + const results = await apply( + owner, + "owner-client", + cases.map((entry) => entry.op), + ); + expect(results).toEqual( + cases.map((entry) => + expect.objectContaining({ + opId: entry.op.opId, + status: "failed", + code: entry.code, + }), + ), + ); + expect(await pageLineups(owner)).toEqual([]); + }); + + test("a spot no lineup uses is kept", async () => { + const { owner } = await createHarness(); + const row = siteGroup((lineups) => { + lineups.origins.push({ id: "unused-origin", agentType: "jett" }); + lineups.landings.push({ id: "unused-landing" }); + }); + const results = await apply(owner, "owner-client", [addOp(row, 0)]); + expect(results[0]).toMatchObject({ status: "applied" }); + expect((await pageLineups(owner))[0]!.payload).toEqual(row.payload); + }); + + test("a patch that breaks the group is refused and leaves the row as it was", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [addOp(siteGroup(), 0)]); + // The landing two lineups aim at is gone, but they still name it. + const broken = siteGroup((lineups) => { + lineups.landings = lineups.landings.filter( + (landing) => landing.id !== "site", + ); + }); + const results = await apply(owner, "owner-client", [ + patchOp("drop-site", broken, 1), + ]); + expect(results[0]).toMatchObject({ + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_DATA", + }); + const row = (await pageLineups(owner))[0]!; + expect(row).toMatchObject({ revision: 1 }); + expect(row.payload).toEqual(siteGroup().payload); + }); + + test("the graph kinds of protocol 4 are refused by the handler; lineup and lineupGroup by the contract", async () => { + const { owner } = await createHarness(); + // Protocol 4's graph rows get past argument validation so an old client + // reaches the protocol gate; on protocol 5 the handler refuses each op. + const graphKinds = ["lineupOrigin", "lineupLanding", "lineupLink"]; + const results = await apply( + owner, + "old-shapes", + graphKinds.map((kind) => ({ + opId: `add-${kind}`, + type: "lineup.add", + lineupPublicId: `${kind}:k`, + pagePublicId, + payload: { + kind, + payloadVersion: 1, + data: { id: "k", originId: "o", landingId: "l", images: [] }, + }, + sortIndex: 0, + })), + ); + expect(results).toHaveLength(graphKinds.length); + for (const [index, kind] of graphKinds.entries()) { + expect(results[index]).toMatchObject({ + opId: `add-${kind}`, + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_KIND", + }); + } + // The group of protocol 3 and earlier, and the one-row-per-lineup shape + // this branch tried before groups, are refused whole: no client that + // could send them speaks protocol 5. + const retired = [ + { + kind: "lineupGroup", + payloadVersion: 1, + data: { id: "group", items: [{ id: "item" }] }, + }, + { + kind: "lineup", + payloadVersion: 1, + data: { + id: "group", + origin: { id: "o", agent: { type: "sova" } }, + landing: { id: "l", ability: {} }, + }, + }, + ]; + for (const payload of retired) { + await expect( + apply(owner, "old-client", [ + { + opId: `add-${payload.kind}`, + type: "lineup.add", + lineupPublicId: "group", + pagePublicId, + payload, + sortIndex: 0, + }, + ]), + ).rejects.toThrow(/Validator error|ArgumentValidationError/); + } + expect(await pageLineups(owner)).toEqual([]); + }); +}); + +describe("no lineup or spot is in two groups of a page", () => { + test("a new group holding a spot another group holds is refused, and nothing is stored", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [ + addOp(lineup("k1", { landingId: "smoke" }), 0), + ]); + const results = await apply(owner, "owner-client", [ + addOp(lineup("k2", { landingId: "smoke" }), 1), + ]); + expect(results[0]).toMatchObject({ + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_DATA", + }); + expect(liveKeys(await pageLineups(owner))).toEqual(["k1"]); + }); + + test("a patch taking a lineup another group holds is refused and leaves the row as it was", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [ + addOp(lineup("k1"), 0), + addOp(lineup("k2"), 1), + ]); + // k2's row also claims k1's lineup, under its own spots. + const grabbing = group("k2", { + origins: [{ id: "k2-origin" }], + landings: [{ id: "k2-landing" }], + links: [ + { id: "k2", originId: "k2-origin", landingId: "k2-landing" }, + { id: "k1", originId: "k2-origin", landingId: "k2-landing" }, + ], + }); + const results = await apply(owner, "owner-client", [ + patchOp("grab-k1", grabbing, 1), + ]); + expect(results[0]).toMatchObject({ + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_DATA", + }); + const rows = byKey(await pageLineups(owner)); + expect(rows.get("k2")).toMatchObject({ revision: 1 }); + expect(rows.get("k2")!.payload).toEqual(lineup("k2").payload); + }); + + test("ids only clash within a kind, within a page, among live rows", async () => { + const { owner } = await createHarness(); + await addSecondPage(owner, "page-2"); + await apply(owner, "owner-client", [ + addOp(lineup("k1", { landingId: "shared" }), 0), + ]); + const results = await apply(owner, "owner-client", [ + // A lineup named like another group's landing. + addOp(lineup("shared", { landingId: "other" }), 1), + // The same spot on another page. + addOp(lineup("k3", { landingId: "shared" }), 0, "page-2"), + ]); + expect(results.map((result) => result.status)).toEqual([ + "applied", + "applied", + ]); + + // Once k1's group is deleted, its spot is free for another group. + await apply(owner, "owner-client", [deleteOp("drop-k1", "k1", 1)]); + const reuse = await apply(owner, "owner-client", [ + addOp(lineup("k4", { landingId: "shared" }), 2), + ]); + expect(reuse[0]).toMatchObject({ status: "applied" }); + }); + + test("a duplicated strategy's groups hold their spots in the copy too", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [addOp(lineup("k1"), 0)]); + await owner.mutation(duplicateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + sourceStrategyPublicId: strategyPublicId, + publicId: "lineups-copy", + name: "Lineups (Copy)", + }); + const copy = (await owner.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "lineups-copy", + })) as { + pages: Array<{ publicId: string }>; + lineups: Array; + }; + const copied = copy.lineups[0]!; + const landingId = ( + copied.payload.data.landings as Array<{ id: string }> + )[0]!.id; + + const results = await apply( + owner, + "owner-client", + [addOp(lineup("k2", { landingId }), 1, copied.pagePublicId)], + "lineups-copy", + ); + expect(results[0]).toMatchObject({ + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_DATA", + }); + }); + + test("restoring a deleted group over a spot another group took since is refused", async () => { + const { owner } = await createHarness(); + await apply(owner, "owner-client", [ + addOp(lineup("k1", { landingId: "smoke" }), 0), + ]); + await apply(owner, "owner-client", [deleteOp("drop-k1", "k1", 1)]); + await apply(owner, "owner-client", [ + addOp(lineup("k2", { landingId: "smoke" }), 1), + ]); + const results = await apply(owner, "owner-client", [ + { + ...addOp(lineup("k1", { landingId: "smoke" }), 0), + opId: "restore-k1", + expectedLineupRevision: 2, + }, + ]); + expect(results[0]).toMatchObject({ + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_DATA", + }); + expect(liveKeys(await pageLineups(owner))).toEqual(["k2"]); + }); +}); + +describe("clients on protocol 4", () => { + test("a protocol 4 batch with its lineup options and a link row is told to upgrade", async () => { + const { owner } = await createHarness(); + const error = await owner + .mutation(applyBatch, { + strategyPublicId, + clientId: "protocol-4-client", + clientProtocolVersion: 4, + checkLineupLinkEnds: true, + checkLineupEndDeletes: true, + ops: [ + { + opId: "add-link", + type: "lineup.add", + lineupPublicId: "lineupLink:k", + pagePublicId, + payload: { + kind: "lineupLink", + payloadVersion: 1, + data: { id: "k", originId: "o", landingId: "l", images: [] }, + }, + sortIndex: 0, + }, + ], + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown; message?: string }, + ); + expect(error).not.toBeNull(); + // The protocol gate answered, not argument validation. + expect(error?.message ?? "").not.toMatch( + /Validator error|ArgumentValidationError/, + ); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toMatchObject({ + code: "CLIENT_UPGRADE_REQUIRED", + }); + expect(await pageLineups(owner)).toEqual([]); + }); + + test("a protocol 5 batch with an origin row fails that op and applies the rest", async () => { + const { owner } = await createHarness(); + const response = (await owner.mutation(applyBatch, { + strategyPublicId, + clientId: "mixed-client", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + // Still accepted (and ignored) on protocol 5. + checkLineupLinkEnds: true, + checkLineupEndDeletes: true, + ops: [ + { + opId: "add-origin", + type: "lineup.add", + lineupPublicId: "lineupOrigin:o", + pagePublicId, + payload: { + kind: "lineupOrigin", + payloadVersion: 1, + data: { id: "o", agent: { type: "sova" } }, + }, + sortIndex: 0, + }, + { + opId: "add-text", + type: "element.add", + elementPublicId: "note", + pagePublicId, + payload: { + kind: "text", + payloadVersion: 1, + data: { id: "note", elementType: "text", text: "Default" }, + }, + sortIndex: 0, + }, + ], + })) as { results: Result[] }; + expect(response.results).toMatchObject([ + { + opId: "add-origin", + status: "failed", + code: "INVALID_LINEUP_PAYLOAD_KIND", + }, + { opId: "add-text", status: "applied", appliedRevision: 1 }, + ]); + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId, + })) as { + elements: Array<{ publicId: string }>; + lineups: LineupRow[]; + }; + expect(snapshot.elements.map((row) => row.publicId)).toEqual(["note"]); + expect(snapshot.lineups).toEqual([]); + }); +}); + +describe("lineup row keys belong to their strategy and page", () => { + test("the same group ids in two strategies are separate rows", async () => { + const { owner } = await createHarness(); + const copyId = "local-duplicate"; + const copyPage = "local-duplicate-page"; + await owner.mutation(createStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: copyId, + name: "Copy", + mapData: "ascent", + initialPagePublicId: copyPage, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + // A strategy duplicated on the device keeps every lineup id; each upload + // keeps them too (ids only need to be unique within a strategy). + for (const [strategy, page] of [ + [strategyPublicId, pagePublicId], + [copyId, copyPage], + ] as const) { + const results = await apply( + owner, + `upload-${strategy}`, + [addOp(siteGroup(), 0, page)], + strategy, + ); + expect(results[0]).toMatchObject({ status: "applied" }); + } + // Editing one copy leaves the other untouched. + await apply( + owner, + "edit-copy", + [ + { + ...patchOp( + "rename-in-copy", + siteGroup((lineups) => { + lineups.links[0]!.name = "Copy name"; + }), + 1, + ), + pagePublicId: copyPage, + }, + ], + copyId, + ); + const firstLinkName = (row: LineupRow | undefined) => + (row!.payload.data as { links: Array<{ name: string }> }).links[0]!.name; + expect(firstLinkName(byKey(await pageLineups(owner)).get(siteKey))).toBe( + "From heaven", + ); + expect( + firstLinkName( + byKey(await pageLineups(owner, copyId, copyPage)).get(siteKey), + ), + ).toBe("Copy name"); + }); + + test("a lineup row is never moved to another page by a patch", async () => { + const { owner } = await createHarness(); + const secondPage = "lineups-page-2"; + await addSecondPage(owner, secondPage); + await apply(owner, "page-1-client", [addOp(lineup("k"), 0)]); + + // Another page wants the same key: the add is refused, and "Keep mine" + // would turn it into a patch naming the other page. + const clashing = lineup("k", { originId: "o2", name: "Page 2 lineup" }); + const added = await apply(owner, "page-2-client", [ + addOp(clashing, 0, secondPage), + ]); + expect(added[0]).toMatchObject({ + status: "rejected", + reason: "already_exists", + }); + const moved = await apply(owner, "page-2-client", [ + { ...patchOp("keep-mine", clashing, 1), pagePublicId: secondPage }, + ]); + expect(moved[0]).toMatchObject({ + status: "failed", + code: "LINEUP_PAGE_MISMATCH", + }); + + // The row stays on its page, untouched. + const first = await pageLineups(owner); + expect(first.map((row) => row.publicId)).toEqual(["k"]); + expect(first[0]!.payload).toEqual(lineup("k").payload); + expect(await pageLineups(owner, strategyPublicId, secondPage)).toEqual([]); + }); + + test("the same group added twice with a different order is one add", async () => { + const { owner, editor } = await createHarness(); + const first = await apply(owner, "owner-client", [addOp(siteGroup(), 3)]); + const second = await apply(editor, "editor-client", [ + addOp(siteGroup(), 7), + ]); + expect(first[0]).toMatchObject({ status: "applied" }); + expect(second[0]).toMatchObject({ status: "noop" }); + const conflicting = await apply(editor, "editor-client", [ + { + ...addOp( + siteGroup((lineups) => { + lineups.origins[0]!.agentType = "jett"; + }), + 7, + ), + opId: "different-content", + }, + ]); + expect(conflicting[0]).toMatchObject({ + status: "rejected", + reason: "already_exists", + }); + }); +}); + +describe("lineup images", () => { + test("a group references every image across its lineups while it lives, and releases them when it is deleted", async () => { + const { t, owner } = await createHarness(); + await t.run(markAssetReferencesReady); + const shots = ["shot-1", "shot-2", "shot-3"]; + await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + const now = Date.now(); + for (const publicId of shots) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy!._id, + objectKey: `tests/${publicId}.png`, + uploadStatus: "active", + fileExtension: ".png", + mimeType: "image/png", + createdAt: now, + updatedAt: now, + }); + } + }); + // Two lineups of the group show images, one of them shared by both. + const withImages = (secondLinkShots: string[]) => + siteGroup((lineups) => { + lineups.links[0]!.images = [ + { id: "shot-1", fileExtension: ".png" }, + { id: "shot-2", fileExtension: ".png" }, + ]; + lineups.links[2]!.images = secondLinkShots.map((id) => ({ + id, + fileExtension: ".png", + })); + }); + await apply(owner, "owner-client", [ + addOp(withImages(["shot-2", "shot-3"]), 0), + ]); + + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId, + })) as { assets: Array<{ publicId: string }> }; + expect(snapshot.assets.map((asset) => asset.publicId).sort()).toEqual( + shots, + ); + // One reference per image the row shows, however many lineups show it. + const lineupReferences = async () => + ( + await t.run(async (ctx) => await ctx.db.query("assetReferences").collect()) + ) + .filter((reference) => reference.lineupId !== undefined) + .map((reference) => reference.assetPublicId) + .sort(); + expect(await lineupReferences()).toEqual(shots); + const asked = { + strategyPublicId, + assetPublicIds: [...shots, "never-shown"], + }; + expect(await owner.query(listReferencedAssetIds, asked)).toEqual(shots); + + // Dropping an image one lineup still shows keeps it; dropping the last + // lineup showing an image releases only that one. + await apply(owner, "owner-client", [ + patchOp("drop-shared-shot", withImages(["shot-3"]), 1), + ]); + expect(await owner.query(listReferencedAssetIds, asked)).toEqual(shots); + await apply(owner, "owner-client", [ + patchOp("drop-shot-3", withImages([]), 2), + ]); + expect(await owner.query(listReferencedAssetIds, asked)).toEqual([ + "shot-1", + "shot-2", + ]); + expect(await lineupReferences()).toEqual(["shot-1", "shot-2"]); + + await apply(owner, "owner-client", [deleteOp("delete-site", siteKey, 3)]); + expect(await owner.query(listReferencedAssetIds, asked)).toEqual([]); + }); +}); + +describe("agent summary", () => { + test("an origin shared by several lineups counts once, distinct origins count each", async () => { + const { t, owner } = await createHarness(); + await apply(owner, "owner-client", [ + // One astra origin, thrown to two landings. + addOp( + group("astra", { + origins: [{ id: "astra-origin", agentType: "astra" }], + landings: [{ id: "a-site" }, { id: "b-site" }], + links: [ + { id: "astra-a", originId: "astra-origin", landingId: "a-site" }, + { id: "astra-b", originId: "astra-origin", landingId: "b-site" }, + ], + }), + 0, + ), + // Two viper origins into one landing. + addOp( + group("viper", { + origins: [ + { id: "viper-1", agentType: "viper" }, + { id: "viper-2", agentType: "viper" }, + ], + landings: [{ id: "wall" }], + links: [ + { id: "viper-a", originId: "viper-1", landingId: "wall" }, + { id: "viper-b", originId: "viper-2", landingId: "wall" }, + ], + }), + 1, + ), + ]); + // Counted per lineup, astra would tie viper and sort first by name; + // counted per origin, viper (2) leads astra (1). + expect(await agentSummary(t)).toEqual(["viper", "astra"]); + + // Deleting the group of an origin drops its agent. + await apply(owner, "owner-client", [deleteOp("delete-astra", "astra", 1)]); + expect(await agentSummary(t)).toEqual(["viper"]); + }); + + test("only a batch that may change the agents refreshes the summary", async () => { + const { t, owner } = await createHarness(); + // A lineup written straight to the table leaves the stored summary + // stale, so a refresh shows as the summary catching up with it. + await seedLineupRow(t, "seeded-viper", "viper"); + expect(await agentSummary(t)).toEqual([]); + + // Drawings, utilities and text never carry an agent: no refresh. + const plain = await apply(owner, "owner-client", [ + elementAdd("drawing-1", "drawing", 0), + elementAdd("utility-1", "utility", 1), + elementAdd("text-1", "text", 2), + { + opId: "patch-text-1", + type: "element.patch", + elementPublicId: "text-1", + pagePublicId, + payload: elementPayload("text-1", "text", { text: "Edited" }), + expectedElementRevision: 1, + }, + { + opId: "delete-drawing-1", + type: "element.delete", + elementPublicId: "drawing-1", + pagePublicId, + expectedElementRevision: 1, + }, + ]); + expect(plain.map((result) => result.status)).toEqual([ + "applied", + "applied", + "applied", + "applied", + "applied", + ]); + expect(await agentSummary(t)).toEqual([]); + + // Placing an agent does. + await apply(owner, "owner-client", [elementAdd("jett-1", "agent", 3)]); + expect(await agentSummary(t)).toEqual(["jett", "viper"]); + + // Deleting an agent does too, though the op carries no payload. + await seedLineupRow(t, "seeded-astra", "astra"); + expect(await agentSummary(t)).toEqual(["jett", "viper"]); + await apply(owner, "owner-client", [ + { + opId: "delete-jett-1", + type: "element.delete", + elementPublicId: "jett-1", + pagePublicId, + expectedElementRevision: 1, + }, + ]); + expect(await agentSummary(t)).toEqual(["astra", "viper"]); + + // And so does any lineup change. + await seedLineupRow(t, "seeded-kayo", "kayo"); + expect(await agentSummary(t)).toEqual(["astra", "viper"]); + await apply(owner, "owner-client", [ + addOp(lineup("placed-sova", { agentType: "sova" }), 9), + ]); + expect(await agentSummary(t)).toEqual(["astra", "kayo", "sova", "viper"]); + }); + + test("each live group keeps one agent row per origin in step with it, and the summary follows them", async () => { + const { t, owner } = await createHarness(); + const secondPage = "lineups-page-2"; + await addSecondPage(owner, secondPage); + const kayo = lineup("kayo-a", { originId: "kayo", agentType: "kayo" }); + const rawRows = async () => + await t.run(async (ctx) => await ctx.db.query("lineupAgents").collect()); + + // Adding a group adds a row for each of its origins. + await apply(owner, "owner-client", [ + addOp(siteGroup(), 0), + addOp(kayo, 0, secondPage), + ]); + expect(await lineupAgentRows(t)).toEqual([ + { lineup: "kayo-a", page: secondPage, originId: "kayo", agentType: "kayo" }, + { lineup: siteKey, page: pagePublicId, originId: "heaven", agentType: "brimstone" }, + { lineup: siteKey, page: pagePublicId, originId: "mid", agentType: "viper" }, + ]); + expect(await agentSummary(t)).toEqual(["brimstone", "kayo", "viper"]); + + // A patch that changes one origin's agent updates that row alone; the + // other origin's row is left exactly as it was. + const before = await rawRows(); + const heavenRow = before.find((row) => row.originId === "heaven")!; + const midRow = before.find((row) => row.originId === "mid")!; + const midToSova = siteGroup((lineups) => { + lineups.origins[1]!.agentType = "sova"; + }); + expect( + (await apply(owner, "owner-client", [patchOp("mid-to-sova", midToSova, 1)]))[0], + ).toMatchObject({ status: "applied" }); + let after = await rawRows(); + expect(after.find((row) => row.originId === "heaven")).toEqual(heavenRow); + expect(after.find((row) => row.originId === "mid")).toEqual({ + ...midRow, + agentType: "sova", + }); + expect(await agentSummary(t)).toEqual(["brimstone", "kayo", "sova"]); + + // A lineup from a new origin adds its row; dropping an origin (and its + // lineup) deletes its row, leaving the rest. + const reshaped = siteGroup((lineups) => { + lineups.origins[1]!.agentType = "sova"; + lineups.origins.push({ id: "garage", agentType: "astra" }); + lineups.links.push({ id: "from-garage", originId: "garage", landingId: "site" }); + }); + await apply(owner, "owner-client", [patchOp("add-garage", reshaped, 2)]); + expect( + (await lineupAgentRows(t)).filter((row) => row.lineup === siteKey), + ).toEqual([ + { lineup: siteKey, page: pagePublicId, originId: "garage", agentType: "astra" }, + { lineup: siteKey, page: pagePublicId, originId: "heaven", agentType: "brimstone" }, + { lineup: siteKey, page: pagePublicId, originId: "mid", agentType: "sova" }, + ]); + const withoutMid = siteGroup((lineups) => { + lineups.origins = lineups.origins.filter((origin) => origin.id !== "mid"); + lineups.origins.push({ id: "garage", agentType: "astra" }); + lineups.links = lineups.links.filter((link) => link.originId !== "mid"); + lineups.links.push({ id: "from-garage", originId: "garage", landingId: "site" }); + }); + await apply(owner, "owner-client", [patchOp("drop-mid", withoutMid, 3)]); + after = await rawRows(); + expect(after.find((row) => row.originId === "heaven")).toEqual(heavenRow); + expect( + (await lineupAgentRows(t)).filter((row) => row.lineup === siteKey), + ).toEqual([ + { lineup: siteKey, page: pagePublicId, originId: "garage", agentType: "astra" }, + { lineup: siteKey, page: pagePublicId, originId: "heaven", agentType: "brimstone" }, + ]); + expect(await agentSummary(t)).toEqual(["astra", "brimstone", "kayo"]); + + // Deleting a group removes all its rows; restoring it brings them back. + await apply(owner, "owner-client", [deleteOp("delete-site", siteKey, 4)]); + expect((await lineupAgentRows(t)).map((row) => row.lineup)).toEqual([ + "kayo-a", + ]); + expect(await agentSummary(t)).toEqual(["kayo"]); + const restored = await apply(owner, "owner-client", [ + { ...addOp(withoutMid, 0), opId: "restore-site", expectedLineupRevision: 5 }, + ]); + expect(restored[0]).toMatchObject({ status: "applied", appliedRevision: 6 }); + expect( + (await lineupAgentRows(t)).map((row) => `${row.lineup}:${row.originId}`), + ).toEqual(["kayo-a:kayo", `${siteKey}:garage`, `${siteKey}:heaven`]); + expect(await agentSummary(t)).toEqual(["astra", "brimstone", "kayo"]); + + // A page in the trash keeps its groups' rows for a restore, and the + // summary leaves them out until the page comes back. + const trashed = await apply(owner, "owner-client", [ + { + opId: "trash-page-2", + type: "page.delete", + pagePublicId: secondPage, + expectedStrategyRevision: await strategyRevision(t), + }, + ]); + expect(trashed[0]).toMatchObject({ status: "applied" }); + expect((await lineupAgentRows(t)).map((row) => row.lineup)).toEqual([ + "kayo-a", + siteKey, + siteKey, + ]); + expect(await agentSummary(t)).toEqual(["astra", "brimstone"]); + await owner.mutation(restorePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: secondPage, + }); + expect(await agentSummary(t)).toEqual(["astra", "brimstone", "kayo"]); + }); + + test("purging leaves no agent row behind, even one a tombstone kept", async () => { + vi.useFakeTimers(); + const { t, owner } = await createHarness(); + await t.run(markAssetReferencesReady); + await apply(owner, "owner-client", [ + addOp(siteGroup(), 0), + addOp(lineup("kept", { agentType: "viper" }), 1), + ]); + await apply(owner, "owner-client", [deleteOp("delete-site", siteKey, 1)]); + expect((await lineupAgentRows(t)).map((row) => row.lineup)).toEqual([ + "kept", + ]); + // A row left behind for the tombstone (say by a write before this + // table existed) goes with the tombstone. + await t.run(async (ctx) => { + const gone = (await ctx.db.query("lineups").collect()).find( + (row) => row.publicId === siteKey, + )!; + await ctx.db.insert("lineupAgents", { + strategyId: gone.strategyId, + pageId: gone.pageId, + lineupId: gone._id, + originId: "heaven", + agentType: "brimstone", + }); + }); + + vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000); + await t.mutation(purgeOldTombstones, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + const left = await t.run(async (ctx) => ({ + lineups: (await ctx.db.query("lineups").collect()).map( + (row) => row.publicId, + ), + lineupAgents: (await ctx.db.query("lineupAgents").collect()).length, + })); + expect(left).toEqual({ lineups: ["kept"], lineupAgents: 1 }); + expect((await lineupAgentRows(t)).map((row) => row.lineup)).toEqual([ + "kept", + ]); + }); + + test("a duplicate's groups get agent rows of their own, one per origin", async () => { + const { t, owner } = await createHarness(); + await apply(owner, "owner-client", [ + addOp(siteGroup(), 0), + addOp(lineup("astra-a", { originId: "astra", agentType: "astra" }), 1), + ]); + const sourceRows = await lineupAgentRows(t); + + await owner.mutation(duplicateStrategy, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + sourceStrategyPublicId: strategyPublicId, + publicId: "lineups-copy", + name: "Lineups (Copy)", + }); + + type Origin = { id: string; agent: { type: string } }; + const copy = (await owner.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "lineups-copy", + })) as { + pages: Array<{ publicId: string }>; + lineups: Array; + }; + const copyRows = await lineupAgentRows(t, "lineups-copy"); + expect(copyRows).toEqual( + copy.lineups + .flatMap((row) => + (row.payload.data.origins as Origin[]).map((origin) => ({ + lineup: row.publicId, + page: row.pagePublicId, + originId: origin.id, + agentType: origin.agent.type, + })), + ) + .sort( + (a, b) => + a.lineup.localeCompare(b.lineup) || + a.originId.localeCompare(b.originId), + ), + ); + expect(copyRows).toHaveLength(3); + expect(copyRows.map((row) => row.page)).toEqual( + copyRows.map(() => copy.pages[0]!.publicId), + ); + // The source keeps its rows untouched. + expect(await lineupAgentRows(t)).toEqual(sourceRows); + expect(await agentSummary(t, "lineups-copy")).toEqual([ + "astra", + "brimstone", + "viper", + ]); + + // Editing the copy moves only the copy's rows. + const copiedSite = copy.lineups.find( + (row) => (row.payload.data.origins as Origin[]).length === 2, + )!; + const data = structuredClone(copiedSite.payload.data) as { + origins: Origin[]; + }; + const viperOrigin = data.origins.find( + (origin) => origin.agent.type === "viper", + )!; + viperOrigin.agent.type = "jett"; + const edited = await apply( + owner, + "copy-client", + [ + { + opId: "copy-viper-to-jett", + type: "lineup.patch", + lineupPublicId: copiedSite.publicId, + pagePublicId: copiedSite.pagePublicId, + payload: { ...copiedSite.payload, data }, + expectedLineupRevision: 1, + }, + ], + "lineups-copy", + ); + expect(edited[0]).toMatchObject({ status: "applied" }); + expect( + (await lineupAgentRows(t, "lineups-copy")).find( + (row) => row.originId === viperOrigin.id, + ), + ).toMatchObject({ lineup: copiedSite.publicId, agentType: "jett" }); + expect(await agentSummary(t, "lineups-copy")).toEqual([ + "astra", + "brimstone", + "jett", + ]); + expect(await lineupAgentRows(t)).toEqual(sourceRows); + expect(await agentSummary(t)).toEqual(["astra", "brimstone", "viper"]); + }); +}); + +describe("a resent lineup op", () => { + test("reports the revision it landed at, so a successor cannot overwrite a teammate's later edit", async () => { + const { owner, editor } = await createHarness(); + await apply(owner, "owner-client", [addOp(siteGroup(), 0)]); + const mine = patchOp( + "owner-renames", + siteGroup((lineups) => { + lineups.links[0]!.name = "Owner name"; + }), + 1, + ); + const first = await apply(owner, "owner-client", [mine]); + expect(first[0]).toMatchObject({ status: "applied", appliedRevision: 2 }); + + // A teammate edits the same group after it. + const theirs = siteGroup((lineups) => { + lineups.links[0]!.name = "Editor name"; + }); + const teammate = await apply(editor, "editor-client", [ + patchOp("editor-renames", theirs, 2), + ]); + expect(teammate[0]).toMatchObject({ status: "applied", appliedRevision: 3 }); + + // The owner's ack was lost: it sends the op again, with the successor it + // queued on top of it (expecting the revision the first landed at). + const resent = await apply(owner, "owner-client", [ + mine, + patchOp( + "owner-notes", + siteGroup((lineups) => { + lineups.links[0]!.name = "Owner name"; + lineups.links[0]!.notes = "Owner notes"; + }), + 2, + ), + ]); + expect(resent[0]).toEqual({ + opId: "owner-renames", + status: "noop", + currentRevision: 2, + }); + expect(resent[1]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { type: "lineup", revision: 3, value: theirs.payload }, + }); + const row = byKey(await pageLineups(owner)).get(siteKey)!; + expect(row.revision).toBe(3); + expect(row.payload).toEqual(theirs.payload); + }); + + test("whose event recorded no revision replays with none, not the row's latest", async () => { + const { t, owner, editor } = await createHarness(); + const mine = addOp(siteGroup(), 0); + const first = await apply(owner, "owner-client", [mine]); + expect(first[0]).toMatchObject({ status: "applied", appliedRevision: 1 }); + // An event written without the revision its op landed at. + await t.run(async (ctx) => { + const event = (await ctx.db.query("operationEvents").collect()).find( + (row) => row.opId === mine.opId, + )!; + await ctx.db.patch(event._id, { appliedRevision: undefined }); + }); + // A teammate moves the row on. + const theirs = siteGroup((lineups) => { + lineups.links[0]!.name = "Editor name"; + }); + await apply(editor, "editor-client", [patchOp("editor-renames", theirs, 1)]); + + // The replay names no revision, so the client cannot rebase a successor + // onto the teammate's edit; the successor, still on 1, meets the usual + // revision check. + const resent = await apply(owner, "owner-client", [ + mine, + patchOp( + "owner-renames", + siteGroup((lineups) => { + lineups.links[0]!.name = "Owner name"; + }), + 1, + ), + ]); + expect(resent[0]).toEqual({ opId: mine.opId, status: "noop" }); + expect(resent[1]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { type: "lineup", revision: 2, value: theirs.payload }, + }); + }); +}); + +function elementPayload( + id: string, + kind: "agent" | "drawing" | "text" | "utility", + extra: Record = {}, +) { + return { + kind, + payloadVersion: 1, + data: { + id, + elementType: kind, + ...(kind === "agent" ? { type: "jett" } : {}), + ...extra, + }, + }; +} + +function elementAdd( + id: string, + kind: "agent" | "drawing" | "text" | "utility", + sortIndex: number, +) { + return { + opId: `add-${id}`, + type: "element.add", + elementPublicId: id, + pagePublicId, + payload: elementPayload(id, kind), + sortIndex, + }; +} + +/// Writes a live group of one lineup straight to the table, bypassing +/// applyBatch and so the agent summary refresh. +async function seedLineupRow(t: RootHarness, id: string, agentType: string) { + await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + const page = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy!._id)) + .first(); + const now = Date.now(); + await insertLineup(ctx, { + publicId: id, + strategyId: strategy!._id, + pageId: page!._id, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload(id, { agentType }), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); +} diff --git a/convex/lineups.ts b/convex/lineups.ts new file mode 100644 index 00000000..0ec38f40 --- /dev/null +++ b/convex/lineups.ts @@ -0,0 +1,89 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import { assertStrategyRole } from "./lib/auth"; +import { + getPageByPublicId, + getStrategyByPublicId, + lineupsOnPages, + listLivePages, +} from "./lib/entities"; +import { errorWithCode } from "./lib/errors"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { lineupValidator } from "./lib/publicValidators"; + +export const listForPage = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + returns: v.array(lineupValidator), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + + const lineups = await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + + return lineups + .sort((a, b) => a.sortIndex - b.sortIndex) + .map((lineup) => ({ + publicId: lineup.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: page.publicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + createdAt: lineup.createdAt, + updatedAt: lineup.updatedAt, + })); + }, +}); + +export const listForStrategy = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + }, + returns: v.array(lineupValidator), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + + const pages = await listLivePages(ctx, strategy._id); + const pagePublicIds = new Map( + pages.map((page) => [page._id, page.publicId]), + ); + + // Page by page: content in the trash is never read. + const lineups = await lineupsOnPages(ctx, pages); + + return lineups + .sort((a, b) => a.sortIndex - b.sortIndex) + .filter((lineup) => pagePublicIds.has(lineup.pageId)) + .map((lineup) => ({ + publicId: lineup.publicId, + strategyPublicId: strategy.publicId, + pagePublicId: pagePublicIds.get(lineup.pageId)!, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + createdAt: lineup.createdAt, + updatedAt: lineup.updatedAt, + })); + }, +}); diff --git a/convex/maintenance.ts b/convex/maintenance.ts new file mode 100644 index 00000000..1cd01f07 --- /dev/null +++ b/convex/maintenance.ts @@ -0,0 +1,456 @@ +import { makeFunctionReference, paginationOptsValidator } from "convex/server"; +import { + internalMutation, + internalQuery, + type MutationCtx, +} from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { + assetIdsOfRow, + assetReferencesReady, + markContentTableBackfilled, + syncElementAssetReferences, + syncLineupAssetReferences, +} from "./lib/assetReferences"; +import { inferUploadStatus } from "./lib/imageAssets"; +import { + processAssetReclaimCandidatesRef, + queueAssetReclaim, + sweepDeletedImageAssetsRef, +} from "./images"; +import { + refreshStrategyAgentSummary, + syncLineupAgents, +} from "./lib/strategyAgentSummary"; +import { PAGE_TRASH_RETENTION_MS } from "./lib/entities"; +import { syncLineupItems } from "./lib/lineupItems"; + +const MAINTENANCE_BATCH_SIZE = 200; +// Content rows can each hold up to ~900 KB (the op size cap), so passes +// that read content take only a few rows per transaction, well under +// Convex's 16 MiB read and write limits, and reschedule themselves. +const CONTENT_BATCH_SIZE = 8; +// Reference rows a purge run turns into reclaim candidates. Each costs one +// indexed read and two writes, far under Convex's 4,096 index reads. +const PURGE_REFERENCE_BUDGET = 200; +// Reference rows the backfill writes per run, well under Convex's 8,192 +// documents written per transaction. +const BACKFILL_WRITE_BUDGET = 2000; +// How long a deleted page's purge waits before checking again whether the +// reference backfill has finished. +const PURGE_WAIT_FOR_BACKFILL_MS = 10 * 60 * 1000; +const DAYS_30_MS = 30 * 24 * 60 * 60 * 1000; + +// NOTE: replace these makeFunctionReference calls with internal.maintenance.* after +// Convex codegen is regenerated and exposes maintenance refs. +export const purgeDeletedPageOrphansRef = makeFunctionReference<"mutation">( + "maintenance:purgeDeletedPageOrphans", +); +export const purgeOldOperationEventsRef = makeFunctionReference<"mutation">( + "maintenance:purgeOldOperationEvents", +); +export const purgeOldTombstonesRef = makeFunctionReference<"mutation">( + "maintenance:purgeOldTombstones", +); +export const backfillAssetReferencesRef = makeFunctionReference<"mutation">( + "maintenance:backfillAssetReferences", +); +export const purgeTrashedPagesRef = makeFunctionReference<"mutation">( + "maintenance:purgeTrashedPages", +); + +/// Hard-deletes content rows, and queues the images they showed for +/// reclaim, in the caller's transaction: a purged row and its reclaim +/// candidates land together or not at all. +/// +/// A row's reference rows are its purge progress. Each one is turned into a +/// reclaim candidate and deleted, at most PURGE_REFERENCE_BUDGET per run +/// (each costs one indexed read to skip a duplicate candidate); the row +/// itself goes once it has none left. A lineup showing hundreds of images +/// is purged across several runs, each committing its part, so a retry +/// resumes where the last run stopped. Returns whether every row was purged. +async function purgeContentRows( + ctx: MutationCtx, + elements: Doc<"elements">[], + lineups: Doc<"lineups">[], +): Promise { + let budget = PURGE_REFERENCE_BUDGET; + const rows: ( + | { strategyId: Id<"strategies">; elementId: Id<"elements"> } + | { strategyId: Id<"strategies">; lineupId: Id<"lineups"> } + )[] = [ + ...elements.map((row) => ({ + strategyId: row.strategyId, + elementId: row._id, + })), + ...lineups.map((row) => ({ + strategyId: row.strategyId, + lineupId: row._id, + })), + ]; + const assetIdsByStrategy = new Map, string[]>(); + let purgedAll = true; + for (const row of rows) { + const references = + "elementId" in row + ? await ctx.db + .query("assetReferences") + .withIndex("by_elementId", (q) => q.eq("elementId", row.elementId)) + .take(budget + 1) + : await ctx.db + .query("assetReferences") + .withIndex("by_lineupId", (q) => q.eq("lineupId", row.lineupId)) + .take(budget + 1); + const handled = references.slice(0, budget); + const assetIds = assetIdsByStrategy.get(row.strategyId) ?? []; + assetIdsByStrategy.set(row.strategyId, assetIds); + for (const reference of handled) { + assetIds.push(reference.assetPublicId); + await ctx.db.delete(reference._id); + } + budget -= handled.length; + if (references.length > handled.length) { + purgedAll = false; + break; + } + if ("lineupId" in row) { + await syncLineupAgents(ctx, row.lineupId, null); + await syncLineupItems(ctx, row.lineupId, null); + await ctx.db.delete(row.lineupId); + } else { + await ctx.db.delete(row.elementId); + } + } + for (const [strategyId, assetPublicIds] of assetIdsByStrategy) { + await queueAssetReclaim(ctx, strategyId, assetPublicIds); + } + return purgedAll; +} + +/// Purges the content of a page whose row is gone: a page of a deleted +/// strategy, or one deleted before pages went to the trash. A page that +/// still has its row, in the trash or not, is left alone. +export const purgeDeletedPageOrphans = internalMutation({ + args: { + pageId: v.id("pages"), + strategyId: v.id("strategies"), + }, + handler: async (ctx, args) => { + if ((await ctx.db.get(args.pageId)) !== null) return; + // A purge reclaims images through reference rows, which content from + // before the reference table only has once the backfill has run. + if (!(await assetReferencesReady(ctx))) { + await ctx.scheduler.runAfter( + PURGE_WAIT_FOR_BACKFILL_MS, + purgeDeletedPageOrphansRef, + args, + ); + return; + } + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", args.pageId)) + .take(CONTENT_BATCH_SIZE); + const remainingSlots = CONTENT_BATCH_SIZE - elements.length; + const lineups = + remainingSlots > 0 + ? await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", args.pageId)) + .take(remainingSlots) + : []; + + const purgedAll = await purgeContentRows(ctx, elements, lineups); + + const shouldContinue = + !purgedAll || + elements.length === CONTENT_BATCH_SIZE || + (remainingSlots > 0 && lineups.length === remainingSlots); + + if (shouldContinue) { + await ctx.scheduler.runAfter( + 0, + purgeDeletedPageOrphansRef, + { pageId: args.pageId, strategyId: args.strategyId }, + ); + } + }, +}); + +/// Purges, for good, pages that have been in the trash for the whole +/// PAGE_TRASH_RETENTION_MS: first their elements and lineups, a few per run +/// (see purgeContentRows, which queues the images they showed for reclaim), +/// then the page and its settings once nothing is left on it. One page at a +/// time, rescheduling itself until no page is due. Restoring refuses a page +/// this old (isPastTrashRetention), so a purge never races a restore. +export const purgeTrashedPages = internalMutation({ + args: {}, + handler: async (ctx) => { + // Waits for the reference backfill, like purgeOldTombstones. + if (!(await assetReferencesReady(ctx))) return; + const cutoff = Date.now() - PAGE_TRASH_RETENTION_MS; + const page = await ctx.db + .query("pages") + .withIndex("by_deletedAt", (q) => + q.gt("deletedAt", 0).lt("deletedAt", cutoff), + ) + .first(); + if (page === null) return; + + const elements = await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .take(CONTENT_BATCH_SIZE); + const remainingSlots = CONTENT_BATCH_SIZE - elements.length; + const lineups = + remainingSlots > 0 + ? await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .take(remainingSlots) + : []; + const purgedAll = await purgeContentRows(ctx, elements, lineups); + if (purgedAll && elements.length + lineups.length < CONTENT_BATCH_SIZE) { + const contents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const content of contents) await ctx.db.delete(content._id); + await ctx.db.delete(page._id); + } + // The next run continues this page, or finds the next one due. + await ctx.scheduler.runAfter(0, purgeTrashedPagesRef, {}); + }, +}); + +export const purgeOldOperationEvents = internalMutation({ + args: {}, + handler: async (ctx) => { + const cutoff = Date.now() - DAYS_30_MS; + const staleEvents = await ctx.db + .query("operationEvents") + .withIndex("by_createdAt", (q) => q.lt("createdAt", cutoff)) + .take(MAINTENANCE_BATCH_SIZE); + + for (const event of staleEvents) { + await ctx.db.delete(event._id); + } + + if (staleEvents.length === MAINTENANCE_BATCH_SIZE) { + await ctx.scheduler.runAfter(0, purgeOldOperationEventsRef, {}); + } + }, +}); + +/// Purges tombstones past their 30-day retention. A tombstone is the last +/// record that its content showed an image, so the images go to the reclaim +/// queue in the same transaction (see purgeContentRows). +export const purgeOldTombstones = internalMutation({ + args: {}, + handler: async (ctx) => { + // Waits for the reference backfill (see purgeDeletedPageOrphans), which + // starts this purge when it finishes. + if (!(await assetReferencesReady(ctx))) return; + const cutoff = Date.now() - DAYS_30_MS; + const staleElements = await ctx.db + .query("elements") + .withIndex("by_deleted_and_updatedAt", (q) => + q.eq("deleted", true).lt("updatedAt", cutoff), + ) + .take(CONTENT_BATCH_SIZE); + + const remainingSlots = CONTENT_BATCH_SIZE - staleElements.length; + const staleLineups = + remainingSlots > 0 + ? await ctx.db + .query("lineups") + .withIndex("by_deleted_and_updatedAt", (q) => + q.eq("deleted", true).lt("updatedAt", cutoff), + ) + .take(remainingSlots) + : []; + + const purgedAll = await purgeContentRows(ctx, staleElements, staleLineups); + + const shouldContinue = + !purgedAll || + staleElements.length === CONTENT_BATCH_SIZE || + (remainingSlots > 0 && staleLineups.length === remainingSlots); + + if (shouldContinue) { + await ctx.scheduler.runAfter(0, purgeOldTombstonesRef, {}); + } + }, +}); + +/// One-off after the asset reference table shipped: gives every existing +/// element and lineup row, tombstones included, its reference rows. Pages +/// through elements, then lineups, a few rows per run (rows can be large), +/// rescheduling itself until done. Rows written meanwhile get theirs from +/// the writer itself. When a table's last page is done it records that +/// table as backfilled. Once both are, every reference-dependent deletion +/// opens (see assetReferencesReady), and the lineups run starts the purge, +/// reclaim, and physical sweep that were waiting. Safe to re-run: syncing a +/// row is idempotent. +export const backfillAssetReferences = internalMutation({ + args: { + table: v.union(v.literal("elements"), v.literal("lineups")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const page = + args.table === "elements" + ? await ctx.db.query("elements").paginate(args.paginationOpts) + : await ctx.db.query("lineups").paginate(args.paginationOpts); + // A row can show thousands of images, so writes are budgeted, not rows. + // A run that spends its budget mid-page runs the same page again: rows + // already in step cost no writes, and the row it stopped in continues. + let writesLeft = BACKFILL_WRITE_BUDGET; + for (const row of page.page) { + const result = + "elementType" in row + ? await syncElementAssetReferences(ctx, row._id, row, writesLeft) + : await syncLineupAssetReferences(ctx, row._id, row, writesLeft); + writesLeft -= result.writes; + if (!result.complete) { + await ctx.scheduler.runAfter(0, backfillAssetReferencesRef, args); + return { synced: page.page.length, isDone: false }; + } + } + if (!page.isDone) { + await ctx.scheduler.runAfter(0, backfillAssetReferencesRef, { + table: args.table, + paginationOpts: { + numItems: args.paginationOpts.numItems, + cursor: page.continueCursor, + }, + }); + return { synced: page.page.length, isDone: false }; + } + await markContentTableBackfilled(ctx, args.table); + if (args.table === "elements") { + await ctx.scheduler.runAfter(0, backfillAssetReferencesRef, { + table: "lineups", + paginationOpts: { numItems: args.paginationOpts.numItems, cursor: null }, + }); + } else if (await assetReferencesReady(ctx)) { + await ctx.scheduler.runAfter(0, purgeOldTombstonesRef, {}); + await ctx.scheduler.runAfter(0, processAssetReclaimCandidatesRef, {}); + await ctx.scheduler.runAfter(0, sweepDeletedImageAssetsRef, {}); + } + return { synced: page.page.length, isDone: true }; + }, +}); + + +/// Verifies the reference backfill, one page of rows at a time: returns +/// the rows whose reference rows do not match the images they show (the +/// set of images, deleted flag, and page). Page with continueCursor until +/// isDone; every page should report no mismatched rows. +export const checkAssetReferences = internalQuery({ + args: { + table: v.union(v.literal("elements"), v.literal("lineups")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const page = + args.table === "elements" + ? await ctx.db.query("elements").paginate(args.paginationOpts) + : await ctx.db.query("lineups").paginate(args.paginationOpts); + const mismatched: string[] = []; + for (const row of page.page) { + const references = + "elementType" in row + ? await ctx.db + .query("assetReferences") + .withIndex("by_elementId", (q) => q.eq("elementId", row._id)) + .collect() + : await ctx.db + .query("assetReferences") + .withIndex("by_lineupId", (q) => q.eq("lineupId", row._id)) + .collect(); + const expected = assetIdsOfRow(row); + const matches = + references.length === expected.size && + references.every( + (reference) => + expected.has(reference.assetPublicId) && + reference.deleted === row.deleted && + reference.pageId === row.pageId, + ); + if (!matches) mismatched.push(row.publicId); + } + return { + checked: page.page.length, + mismatched, + isDone: page.isDone, + continueCursor: page.continueCursor, + }; + }, +}); + +/// Read-only safety check, usable before and after the reference backfill: +/// reads live content itself (not reference rows) and lists every image it +/// shows that has no active asset, with the statuses of that image's asset +/// rows (deleted, pending, failed; none at all if the rows are gone). An +/// image mid-upload shows as pending; a shown image marked deleted is the +/// case cleanup must never cause. Page with continueCursor until isDone; +/// its cost is one indexed read per image shown, so keep pages small. +export const findShownUnavailableAssets = internalQuery({ + args: { + table: v.union(v.literal("elements"), v.literal("lineups")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const page = + args.table === "elements" + ? await ctx.db.query("elements").paginate(args.paginationOpts) + : await ctx.db.query("lineups").paginate(args.paginationOpts); + const unavailable: { + content: string; + assetPublicId: string; + statuses: string[]; + }[] = []; + for (const row of page.page) { + if (row.deleted) continue; + for (const assetPublicId of assetIdsOfRow(row)) { + // One indexed read per image, so a page's cost is its images. + const rows = await ctx.db + .query("imageAssets") + .withIndex("by_strategyId_and_publicId", (q) => + q.eq("strategyId", row.strategyId).eq("publicId", assetPublicId), + ) + .take(20); + if (rows.some((asset) => inferUploadStatus(asset) === "active")) { + continue; + } + unavailable.push({ + content: row.publicId, + assetPublicId, + statuses: rows.map((asset) => inferUploadStatus(asset)), + }); + } + } + return { + checked: page.page.length, + unavailable, + isDone: page.isDone, + continueCursor: page.continueCursor, + }; + }, +}); + +/// One-off after the agent summary table shipped: every strategy written +/// before it needs its summary computed once. Safe to re-run. +export const backfillStrategyAgentSummaries = internalMutation({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const strategies = await ctx.db.query("strategies").collect(); + for (const strategy of strategies) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + return strategies.length; + }, +}); diff --git a/convex/mediaLimits.test.ts b/convex/mediaLimits.test.ts new file mode 100644 index 00000000..aa2c2542 --- /dev/null +++ b/convex/mediaLimits.test.ts @@ -0,0 +1,1425 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { getConvexSize } from "convex/values"; +import type { DataModel, Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; +import * as images from "./images"; +import { processAssetReclaimBatch } from "./images"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; +import * as maintenance from "./maintenance"; +import * as ops from "./ops"; +import schema from "./schema"; +import * as strategies from "./strategies"; +import { modules } from "./test.setup"; +import { + insertElement, + insertLineup, + oneLineupPayload, +} from "./testContent.helpers"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const duplicateStrategy = makeFunctionReference<"mutation">( + "strategies:duplicate", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const purgeOldTombstones = makeFunctionReference<"mutation">( + "maintenance:purgeOldTombstones", +); +const backfillAssetReferences = makeFunctionReference<"mutation">( + "maintenance:backfillAssetReferences", +); +const checkAssetReferences = makeFunctionReference<"query">( + "maintenance:checkAssetReferences", +); +const findShownUnavailableAssets = makeFunctionReference<"query">( + "maintenance:findShownUnavailableAssets", +); +const claimDeletedImageAssets = makeFunctionReference<"mutation">( + "images:claimDeletedImageAssets", +); +const completeLegacyUpload = makeFunctionReference<"mutation">( + "images:completeLegacyUpload", +); +const sweepDeletedImageAssets = makeFunctionReference<"action">( + "images:sweepDeletedImageAssets", +); +const processAssetReclaimCandidates = makeFunctionReference<"mutation">( + "images:processAssetReclaimCandidates", +); +const markPurgedTombstoneImageAssets = makeFunctionReference<"mutation">( + "images:markPurgedTombstoneImageAssets", +); +const deleteAssetRef = makeFunctionReference<"action">("images:deleteAssetRef"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; +const strategyPublicId = "media-limits"; +const pagePublicId = "media-limits-page"; +const day = 24 * 60 * 60 * 1000; +// Rows as large as the op size cap allows, the way Codex's replay built them. +const largeText = "x".repeat(700 * 1024); + +/// A deployment with one strategy. By default its reference backfill has +/// run, as every deployment's will have; [backfilled] false is the window +/// between deploying this version and running the backfill. +async function createHarness( + { backfilled }: { backfilled: boolean } = { backfilled: true }, +): Promise<{ t: RootHarness; owner: Harness }> { + const t = convexTest(schema, modules); + if (backfilled) await t.run(markAssetReferencesReady); + const owner = t.withIdentity({ + issuer: "https://media-limits.test", + subject: "owner", + tokenIdentifier: "media-limits|owner", + name: "Owner", + }); + await owner.mutation(ensureCurrentUser, protocol); + await owner.mutation(createStrategy, { + ...protocol, + publicId: strategyPublicId, + name: "Big", + mapData: "ascent", + initialPagePublicId: pagePublicId, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + return { t, owner }; +} + +async function ids(t: RootHarness) { + return await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + const page = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .unique(); + return { strategyId: strategy!._id, pageId: page!._id }; + }); +} + +/// [rows] large text elements, and (optionally) an image element deleted +/// 40 days ago whose asset is still active. +async function seedLargeStrategy( + t: RootHarness, + rows: number, + { + oldImageTombstone, + text = largeText, + }: { oldImageTombstone: boolean; text?: string }, +) { + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < rows; index++) { + await insertElement(ctx, { + publicId: `text-${index}`, + strategyId, + pageId, + elementType: "text", + payloadKind: "text", + payloadVersion: 1, + payload: { + kind: "text", + payloadVersion: 1, + data: { id: `text-${index}`, elementType: "text", text }, + }, + sortIndex: index, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + } + if (!oldImageTombstone) return; + const longAgo = now - 40 * day; + await ctx.db.insert("imageAssets", { + publicId: "old-image", + provider: "r2", + strategyId, + objectKey: "tests/old-image.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: longAgo, + updatedAt: longAgo, + }); + await insertElement(ctx, { + publicId: "old-image", + strategyId, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: "old-image", elementType: "image" }, + }, + sortIndex: 999, + revision: 2, + deleted: true, + createdAt: longAgo, + updatedAt: longAgo, + }); + }); + return { strategyId, pageId }; +} + +/// [ctx] with every document its queries and gets return counted, in +/// stored bytes, and every query run counted as one index range read: a +/// stand-in for Convex's per-transaction read accounting (convex-test does +/// not enforce the limits). +function countReads(ctx: Ctx) { + let bytes = 0; + let rangeReads = 0; + let writes = 0; + const count = (doc: T): T => { + if (doc !== null && doc !== undefined) bytes += getConvexSize(doc as any); + return doc; + }; + const wrapQuery = (query: any): any => + new Proxy(query, { + get(target, prop) { + if (prop === Symbol.asyncIterator) { + return async function* () { + rangeReads += 1; + for await (const doc of target) yield count(doc); + }; + } + const value = Reflect.get(target, prop, target); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + const result = value.apply(target, args); + if (prop === "first" || prop === "unique") { + rangeReads += 1; + return result.then(count); + } + if (prop === "take" || prop === "collect") { + rangeReads += 1; + return result.then((docs: unknown[]) => docs.map(count)); + } + if (prop === "paginate") { + rangeReads += 1; + return result.then((page: { page: unknown[] }) => { + page.page.forEach(count); + return page; + }); + } + return wrapQuery(result); + }; + }, + }); + const db = new Proxy(ctx.db, { + get(target, prop) { + if (prop === "query") { + return (table: string) => wrapQuery((target as any).query(table)); + } + if (prop === "get") { + return (id: any) => (target as any).get(id).then(count); + } + if (prop === "insert" || prop === "patch" || prop === "delete") { + return (...args: unknown[]) => { + writes += 1; + return (target as any)[prop](...args); + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return { + ctx: { ...ctx, db } as Ctx, + bytesRead: () => bytes, + rangeReads: () => rangeReads, + writes: () => writes, + }; +} + +async function candidates(t: RootHarness) { + return await t.run( + async (ctx) => await ctx.db.query("assetReclaimCandidates").collect(), + ); +} + +async function assetStatus(t: RootHarness, publicId: string) { + return await t.run(async (ctx) => + (await ctx.db.query("imageAssets").collect()) + .filter((asset) => asset.publicId === publicId) + .map((asset) => asset.uploadStatus), + ); +} + +/** A ConvexError's code (convex-test passes its data as JSON text). */ +function errorCode(error: unknown): unknown { + const data = (error as { data?: unknown }).data; + return (typeof data === "string" ? JSON.parse(data) : data)?.code; +} + +beforeEach(() => { + // Scheduled work (the reclaim a purge starts) runs only when a test asks. + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("media cleanup stays within transaction limits", () => { + test("reclaiming an image reads its references, not the strategy's content", async () => { + const { t } = await createHarness(); + // Codex's replay: thirteen 700 KiB rows, 18.6 MB read to reclaim one + // image before, over Convex's 16 MiB transaction read limit. + await seedLargeStrategy(t, 13, { oldImageTombstone: true }); + + await t.mutation(purgeOldTombstones, {}); + expect((await candidates(t)).map((row) => row.assetPublicId)).toEqual([ + "old-image", + ]); + + const result = await t.run(async (ctx) => { + const counted = countReads(ctx); + const outcome = await processAssetReclaimBatch(counted.ctx); + return { outcome, bytesRead: counted.bytesRead() }; + }); + + expect(result.outcome).toEqual({ checked: 1, marked: 1 }); + // A few small index rows, nowhere near the 9 MiB of text on the page. + expect(result.bytesRead).toBeLessThan(16 * 1024); + expect(await assetStatus(t, "old-image")).toEqual(["deleted"]); + expect(await candidates(t)).toEqual([]); + }); + + test("the purge queues its images in the transaction that removes the tombstones", async () => { + const { t } = await createHarness(); + await seedLargeStrategy(t, 1, { oldImageTombstone: true }); + + await t.mutation(purgeOldTombstones, {}); + + // Before any reclaim runs: the tombstone and its reference are gone and + // the candidate is already recorded, so no window loses the image. + const state = await t.run(async (ctx) => ({ + tombstone: await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", "old-image")) + .first(), + references: await ctx.db.query("assetReferences").collect(), + })); + expect(state.tombstone).toBeNull(); + expect( + state.references.filter((row) => row.assetPublicId === "old-image"), + ).toEqual([]); + expect((await candidates(t)).map((row) => row.assetPublicId)).toEqual([ + "old-image", + ]); + }); + + test("a reclaim run that fails leaves its candidates queued", async () => { + const { t } = await createHarness(); + await seedLargeStrategy(t, 1, { oldImageTombstone: true }); + await t.mutation(purgeOldTombstones, {}); + + await expect( + t.run(async (ctx) => { + await processAssetReclaimBatch(ctx); + throw new Error("simulated failure after the batch ran"); + }), + ).rejects.toThrow("simulated failure"); + + // Rolled back: still queued, image untouched. + expect((await candidates(t)).map((row) => row.assetPublicId)).toEqual([ + "old-image", + ]); + expect(await assetStatus(t, "old-image")).toEqual(["active"]); + + // The next run reclaims it. + await t.run(async (ctx) => { + await processAssetReclaimBatch(ctx); + }); + expect(await assetStatus(t, "old-image")).toEqual(["deleted"]); + expect(await candidates(t)).toEqual([]); + }); + + test("an image a live lineup still shows is kept", async () => { + const { t, owner } = await createHarness(); + await seedLargeStrategy(t, 1, { oldImageTombstone: true }); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "editor", + ops: [ + { + opId: "link-shows-old-image", + type: "lineup.add", + lineupPublicId: "link", + pagePublicId, + payload: oneLineupPayload("link", { images: [{ id: "old-image" }] }), + sortIndex: 0, + }, + ], + }); + await t.mutation(purgeOldTombstones, {}); + await t.run(async (ctx) => { + await processAssetReclaimBatch(ctx); + }); + expect(await assetStatus(t, "old-image")).toEqual(["active"]); + expect(await candidates(t)).toEqual([]); + }); +}); + +describe("asset references follow their content", () => { + test("ops keep an image's reference in step: add, delete, restore", async () => { + const { t, owner } = await createHarness(); + const references = async () => + await t.run(async (ctx) => + (await ctx.db.query("assetReferences").collect()).map((row) => ({ + assetPublicId: row.assetPublicId, + deleted: row.deleted, + source: row.elementId !== undefined ? "element" : "lineup", + })), + ); + const image = (id: string) => ({ + kind: "image", + payloadVersion: 1, + data: { id, elementType: "image" }, + }); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "editor", + ops: [ + { + opId: "add-image", + type: "element.add", + elementPublicId: "placed", + pagePublicId, + payload: image("placed"), + sortIndex: 0, + }, + { + opId: "add-link", + type: "lineup.add", + lineupPublicId: "k", + pagePublicId, + payload: oneLineupPayload("k", { images: [{ id: "a" }, { id: "b" }] }), + sortIndex: 0, + }, + ], + }); + expect(await references()).toEqual( + expect.arrayContaining([ + { assetPublicId: "placed", deleted: false, source: "element" }, + { assetPublicId: "a", deleted: false, source: "lineup" }, + { assetPublicId: "b", deleted: false, source: "lineup" }, + ]), + ); + + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "editor", + ops: [ + { + opId: "delete-image", + type: "element.delete", + elementPublicId: "placed", + pagePublicId, + expectedElementRevision: 1, + }, + { + opId: "drop-b", + type: "lineup.patch", + lineupPublicId: "k", + pagePublicId, + payload: oneLineupPayload("k", { images: [{ id: "a" }] }), + expectedLineupRevision: 1, + }, + ], + }); + const afterDelete = await references(); + expect(afterDelete).toHaveLength(2); + expect(afterDelete).toEqual( + expect.arrayContaining([ + { assetPublicId: "placed", deleted: true, source: "element" }, + { assetPublicId: "a", deleted: false, source: "lineup" }, + ]), + ); + + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "editor", + ops: [ + { + opId: "restore-image", + type: "element.add", + elementPublicId: "placed", + pagePublicId, + payload: image("placed"), + sortIndex: 0, + expectedElementRevision: 2, + }, + ], + }); + expect(await references()).toEqual( + expect.arrayContaining([ + { assetPublicId: "placed", deleted: false, source: "element" }, + ]), + ); + }); + + test("the backfill gives existing content its references, then opens the gate", async () => { + const { t } = await createHarness({ backfilled: false }); + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + // Written before the reference table existed: no reference rows. + await ctx.db.insert("elements", { + publicId: "before-image", + strategyId, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: "before-image", elementType: "image" }, + }, + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + for (let index = 0; index < 3; index++) { + await ctx.db.insert("lineups", { + publicId: `before-${index}`, + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload(`before-${index}`, { + images: [{ id: `link-image-${index}` }], + }), + sortIndex: index, + revision: 1, + deleted: index === 2, + createdAt: now, + updatedAt: now, + }); + } + }); + + const mismatched = async () => [ + ...( + await t.query(checkAssetReferences, { + table: "elements", + paginationOpts: { numItems: 100, cursor: null }, + }) + ).mismatched, + ...( + await t.query(checkAssetReferences, { + table: "lineups", + paginationOpts: { numItems: 100, cursor: null }, + }) + ).mismatched, + ]; + expect((await mismatched()).sort()).toEqual([ + "before-0", + "before-1", + "before-2", + "before-image", + ]); + + await t.mutation(backfillAssetReferences, { + table: "elements", + paginationOpts: { numItems: 1, cursor: null }, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await mismatched()).toEqual([]); + + const references = await t.run(async (ctx) => + (await ctx.db.query("assetReferences").collect()) + .map((row) => `${row.assetPublicId}:${row.deleted}`) + .sort(), + ); + expect(references).toEqual([ + "before-image:false", + "link-image-0:false", + "link-image-1:false", + "link-image-2:true", + ]); + const completed = await t.run( + async (ctx) => await ctx.db.query("completedBackfills").collect(), + ); + expect(completed.map((row) => row.name).sort()).toEqual([ + "assetReferences:elements", + "assetReferences:lineups", + ]); + }); +}); + +describe("before the reference backfill", () => { + test("nothing is deleted on the say-so of missing references", async () => { + const { t, owner } = await createHarness({ backfilled: false }); + const { strategyId, pageId } = await ids(t); + const longAgo = Date.now() - 40 * day; + // Written by the previous version: an image only an old tombstone + // shows, and one a live lineup also shows. No reference rows. + await t.run(async (ctx) => { + for (const publicId of ["tombstone-only", "lineup-shown"]) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId, + objectKey: `tests/${publicId}.png`, + uploadStatus: "active", + fileExtension: ".png", + createdAt: longAgo, + updatedAt: longAgo, + }); + await ctx.db.insert("elements", { + publicId, + strategyId, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: publicId, elementType: "image" }, + }, + sortIndex: 0, + revision: 2, + deleted: true, + createdAt: longAgo, + updatedAt: longAgo, + }); + } + await ctx.db.insert("lineups", { + publicId: "live", + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload("live", { images: [{ id: "lineup-shown" }] }), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: longAgo, + updatedAt: longAgo, + }); + }); + + // Everything that deletes on a reference check, before the backfill: + // the tombstone purge, a cleanup the previous version scheduled, the + // reclaim worker, and a user deleting an image. + await t.mutation(purgeOldTombstones, {}); + await t.mutation(markPurgedTombstoneImageAssets, { + strategyId, + assetPublicIds: ["tombstone-only", "lineup-shown"], + }); + await t.mutation(processAssetReclaimCandidates, {}); + await expect( + owner.action(deleteAssetRef, { + ...protocol, + strategyPublicId, + assetPublicId: "lineup-shown", + }), + ).rejects.toThrow("still referenced"); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await assetStatus(t, "tombstone-only")).toEqual(["active"]); + expect(await assetStatus(t, "lineup-shown")).toEqual(["active"]); + const tombstones = await t.run(async (ctx) => + (await ctx.db.query("elements").collect()).filter((row) => row.deleted), + ); + expect(tombstones).toHaveLength(2); + // The old run's images wait in the queue for the backfill. + expect((await candidates(t)).map((row) => row.assetPublicId).sort()).toEqual( + ["lineup-shown", "tombstone-only"], + ); + + // The backfill opens the gate, then the purge and reclaim it starts run. + // (Run by hand: the physical sweep after them needs R2.) + await t.mutation(backfillAssetReferences, { + table: "elements", + paginationOpts: { numItems: 8, cursor: null }, + }); + expect( + await t.mutation(backfillAssetReferences, { + table: "lineups", + paginationOpts: { numItems: 8, cursor: null }, + }), + ).toEqual({ synced: 1, isDone: true }); + await t.mutation(purgeOldTombstones, {}); + await t.mutation(processAssetReclaimCandidates, {}); + + expect(await assetStatus(t, "tombstone-only")).toEqual(["deleted"]); + expect(await assetStatus(t, "lineup-shown")).toEqual(["active"]); + const remaining = await t.run( + async (ctx) => await ctx.db.query("elements").collect(), + ); + expect(remaining.filter((row) => row.deleted)).toEqual([]); + expect(await candidates(t)).toEqual([]); + }); +}); + +describe("the backfill and its gate", () => { + /// A lineup showing [images] images, inserted without reference rows, as + /// the previous version wrote it. + async function insertOldLineup( + t: RootHarness, + linkId: string, + images: number, + ) { + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("lineups", { + publicId: linkId, + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload(linkId, { + images: Array.from({ length: images }, (_, index) => ({ + id: `${linkId}-${index}`, + })), + }), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + } + + test("image-heavy lineups are backfilled across runs, each within its write budget", async () => { + const { t } = await createHarness({ backfilled: false }); + // Codex's replay: 8 lineups x 2,100 images, 16,800 reference rows in one + // run before, past Convex's 8,192 writes. + for (let index = 0; index < 8; index++) { + await insertOldLineup(t, `heavy-${index}`, 2100); + } + const args = { + table: "lineups" as const, + paginationOpts: { numItems: 8, cursor: null }, + }; + + const firstRun = await t.run(async (ctx) => { + const counted = countReads(ctx); + const result = await (maintenance.backfillAssetReferences as any) + ._handler(counted.ctx, args); + return { result, writes: counted.writes() }; + }); + expect(firstRun.result.isDone).toBe(false); + expect(firstRun.writes).toBeLessThanOrEqual(2000); + + // Each run commits its writes; the same page runs again and continues. + await t.mutation(backfillAssetReferences, args); + await t.finishAllScheduledFunctions(vi.runAllTimers); + const state = await t.run(async (ctx) => ({ + references: (await ctx.db.query("assetReferences").collect()).length, + completed: (await ctx.db.query("completedBackfills").collect()).map( + (row) => row.name, + ), + })); + expect(state.references).toBe(16800); + // Lineups are done, but elements have not been backfilled: still gated. + expect(state.completed).toEqual(["assetReferences:lineups"]); + }); + + test("the gate opens only once both tables are backfilled, in either order", async () => { + const { t } = await createHarness({ backfilled: false }); + const { strategyId, pageId } = await ids(t); + // A live placed image from the previous version: no reference row. + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "live-image", + provider: "r2", + strategyId, + objectKey: "tests/live-image.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("elements", { + publicId: "live-image", + strategyId, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: "live-image", elementType: "image" }, + }, + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + await t.mutation(markPurgedTombstoneImageAssets, { + strategyId, + assetPublicIds: ["live-image"], + }); + + // Codex's replay: the lineups backfill first, on an empty table. + await t.mutation(backfillAssetReferences, { + table: "lineups", + paginationOpts: { numItems: 8, cursor: null }, + }); + await t.mutation(processAssetReclaimCandidates, {}); + expect(await assetStatus(t, "live-image")).toEqual(["active"]); + expect((await candidates(t)).map((row) => row.assetPublicId)).toEqual([ + "live-image", + ]); + + // Elements finish too: the gate opens and the element keeps its image. + await t.mutation(backfillAssetReferences, { + table: "elements", + paginationOpts: { numItems: 8, cursor: null }, + }); + await t.mutation(backfillAssetReferences, { + table: "lineups", + paginationOpts: { numItems: 8, cursor: null }, + }); + await t.mutation(processAssetReclaimCandidates, {}); + expect(await assetStatus(t, "live-image")).toEqual(["active"]); + expect(await candidates(t)).toEqual([]); + }); + + test("an old cleanup job with thousands of images queues them in bounded runs", async () => { + const { t } = await createHarness({ backfilled: false }); + const { strategyId } = await ids(t); + const assetPublicIds = Array.from( + { length: 4250 }, + (_, index) => `old-${index}`, + ); + + const firstRun = await t.run(async (ctx) => { + const counted = countReads(ctx); + await (images.markPurgedTombstoneImageAssets as any)._handler( + counted.ctx, + { strategyId, assetPublicIds }, + ); + return counted.rangeReads(); + }); + // Before: 4,250 index reads in one run, past Convex's 4,096. + expect(firstRun).toBeLessThanOrEqual(110); + expect(await candidates(t)).toHaveLength(100); + + // The rest was scheduled in the same transaction, never lost. + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await candidates(t)).toHaveLength(4250); + }); + + test("stored bytes are not removed before the backfill", async () => { + const { t } = await createHarness({ backfilled: false }); + const { strategyId } = await ids(t); + // Marked deleted, and waiting for the physical sweep. + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "marked", + provider: "r2", + strategyId, + objectKey: "tests/marked.png", + uploadStatus: "deleted", + deletedAt: now, + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + }); + + expect(await t.mutation(claimDeletedImageAssets, {})).toEqual([]); + expect(await assetStatus(t, "marked")).toEqual(["deleted"]); + + await t.run(markAssetReferencesReady); + expect(await t.mutation(claimDeletedImageAssets, {})).toHaveLength(1); + }); + + test("a replaced legacy blob waits for the backfill, then the sweep removes it", async () => { + const { t, owner } = await createHarness({ backfilled: false }); + const { strategyId } = await ids(t); + const { oldBlob, newBlob } = await t.run(async (ctx) => { + const oldBlob = await ctx.storage.store(new Blob(["old"])); + const newBlob = await ctx.storage.store(new Blob(["new"])); + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "legacy-image", + provider: "convex", + strategyId, + storageId: oldBlob, + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + return { oldBlob, newBlob }; + }); + + await owner.mutation(completeLegacyUpload, { + strategyPublicId, + assetPublicId: "legacy-image", + storageId: newBlob, + fileExtension: ".png", + }); + await t.action(sweepDeletedImageAssets, {}); + const blobExists = async (id: typeof oldBlob) => + await t.run(async (ctx) => (await ctx.storage.get(id)) !== null); + expect(await blobExists(oldBlob)).toBe(true); + expect((await assetStatus(t, "legacy-image")).sort()).toEqual([ + "active", + "deleted", + ]); + + await t.run(markAssetReferencesReady); + await t.action(sweepDeletedImageAssets, {}); + expect(await blobExists(oldBlob)).toBe(false); + expect(await blobExists(newBlob)).toBe(true); + expect(await assetStatus(t, "legacy-image")).toEqual(["active"]); + }); + + test("the safety check lists shown images whose assets are not active", async () => { + const { t } = await createHarness({ backfilled: false }); + const { strategyId } = await ids(t); + await insertOldLineup(t, "shown", 3); + await t.run(async (ctx) => { + const now = Date.now(); + const statuses = ["active", "deleted", "pending"] as const; + for (let index = 0; index < 3; index++) { + await ctx.db.insert("imageAssets", { + publicId: `shown-${index}`, + provider: "r2", + strategyId, + objectKey: `tests/shown-${index}.png`, + uploadStatus: statuses[index], + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + } + }); + + const result = await t.query(findShownUnavailableAssets, { + table: "lineups", + paginationOpts: { numItems: 100, cursor: null }, + }); + expect(result.unavailable).toEqual([ + { + content: "shown", + assetPublicId: "shown-1", + statuses: ["deleted"], + }, + { + content: "shown", + assetPublicId: "shown-2", + statuses: ["pending"], + }, + ]); + }); +}); + +describe("purges stay within transaction limits", () => { + test("lineups showing hundreds of images are purged across runs, each bounded", async () => { + const { t } = await createHarness(); + const { strategyId, pageId } = await ids(t); + const longAgo = Date.now() - 40 * day; + // Codex's replay: five tombstoned lineups showing 850 images each. + await t.run(async (ctx) => { + for (let lineup = 0; lineup < 5; lineup++) { + await insertLineup(ctx, { + publicId: `many-${lineup}`, + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload(`many-${lineup}`, { + images: Array.from({ length: 850 }, (_, image) => ({ + id: `image-${lineup}-${image}`, + })), + }), + sortIndex: lineup, + revision: 2, + deleted: true, + createdAt: longAgo, + updatedAt: longAgo, + }); + } + }); + const state = async () => + await t.run(async (ctx) => ({ + lineups: (await ctx.db.query("lineups").collect()).length, + references: (await ctx.db.query("assetReferences").collect()).length, + candidates: (await ctx.db.query("assetReclaimCandidates").collect()) + .length, + })); + + const firstRun = await t.run(async (ctx) => { + const counted = countReads(ctx); + await (maintenance.purgeOldTombstones as any)._handler(counted.ctx, {}); + return counted.rangeReads(); + }); + // Before: one run, 4,257 index reads, past Convex's 4,096. + expect(firstRun).toBeLessThan(300); + expect(await state()).toEqual({ + lineups: 5, + references: 4250 - 200, + candidates: 200, + }); + + // Each run commits its part, so the next resumes where it stopped. + let runs = 1; + while ((await state()).lineups > 0) { + await t.mutation(purgeOldTombstones, {}); + runs += 1; + expect(runs).toBeLessThanOrEqual(22); + } + expect(await state()).toEqual({ + lineups: 0, + references: 0, + candidates: 4250, + }); + }); +}); + +describe("duplicate stays within transaction limits", () => { + async function strategyExists(t: RootHarness, publicId: string) { + return await t.run( + async (ctx) => + (await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first()) !== null, + ); + } + + test("a strategy over the size limit is refused whole, with a clear error", async () => { + const { t, owner } = await createHarness(); + // 18 x 700 KiB = 12.3 MiB, past the 12 MiB duplicate budget. + await seedLargeStrategy(t, 18, { oldImageTombstone: false }); + + const error = await owner + .mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "too-big-copy", + name: "Copy", + }) + .then( + () => null, + (caught: unknown) => caught, + ); + + expect(errorCode(error)).toBe("STRATEGY_TOO_LARGE_TO_DUPLICATE"); + expect(await strategyExists(t, "too-big-copy")).toBe(false); + }); + + test("a strategy just under the limit duplicates, with its references", async () => { + const { t, owner } = await createHarness(); + await seedLargeStrategy(t, 17, { oldImageTombstone: false }); + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + await ctx.db.insert("imageAssets", { + publicId: "placed", + provider: "r2", + strategyId, + objectKey: "tests/placed.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await insertElement(ctx, { + publicId: "placed", + strategyId, + pageId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: "placed", elementType: "image" }, + }, + sortIndex: 99, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + + await expect( + owner.mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "copy", + name: "Copy", + }), + ).resolves.toEqual({ ok: true }); + + // The copy's image element got a reference row of its own. + const copyReferences = await t.run(async (ctx) => { + const copy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "copy")) + .unique(); + return await ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_deleted", (q) => + q.eq("strategyId", copy!._id as Id<"strategies">).eq("deleted", false), + ) + .collect(); + }); + expect(copyReferences).toHaveLength(1); + }); + + test("the size limit counts stored bytes, not characters", async () => { + const { t, owner } = await createHarness(); + // 界 is three bytes stored. 15 rows of 290K characters are 4.5 M + // characters, but 13 MB stored, past the 12 MiB budget. + await seedLargeStrategy(t, 15, { + oldImageTombstone: false, + text: "界".repeat(290 * 1024), + }); + + const error = await owner + .mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "wide-copy", + name: "Copy", + }) + .then( + () => null, + (caught: unknown) => caught, + ); + expect(errorCode(error)).toBe("STRATEGY_TOO_LARGE_TO_DUPLICATE"); + expect(await strategyExists(t, "wide-copy")).toBe(false); + }); + + test("a duplicate reads its source once: no reread for the summary or references", async () => { + const { t, owner } = await createHarness(); + const { strategyId, pageId } = await ids(t); + // Codex's replay: thirteen 700 KiB agents read 18.6 MB before. + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 13; index++) { + await insertElement(ctx, { + publicId: `agent-${index}`, + strategyId, + pageId, + elementType: "agent", + payloadKind: "agent", + payloadVersion: 1, + payload: { + kind: "agent", + payloadVersion: 1, + data: { + id: `agent-${index}`, + elementType: "agent", + type: "jett", + note: largeText, + }, + }, + sortIndex: index, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + } + }); + const sourceBytes = await t.run(async (ctx) => + (await ctx.db.query("elements").collect()) + .map((row) => getConvexSize(row as any)) + .reduce((a, b) => a + b, 0), + ); + + const bytesRead = await owner.run(async (ctx) => { + const counted = countReads(ctx); + await (strategies.duplicate as any)._handler(counted.ctx, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "agents-copy", + name: "Copy", + }); + return counted.bytesRead(); + }); + + expect(bytesRead).toBeGreaterThanOrEqual(sourceBytes); + expect(bytesRead).toBeLessThan(sourceBytes + 64 * 1024); + const summary = await t.run(async (ctx) => { + const copy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "agents-copy")) + .unique(); + return await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", copy!._id)) + .unique(); + }); + expect(summary?.agentTypes).toEqual(["jett"]); + }); + + test("the agent summary never reads lineup rows, however large they are", async () => { + const { t, owner } = await createHarness(); + const { strategyId, pageId } = await ids(t); + // 25 lineups as large as the op size cap allows: more than the 16 MiB a + // transaction may read, were the summary to read them. + const agentTypes = ["sova", "kayo", "viper"]; + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 25; index++) { + await insertLineup(ctx, { + publicId: `big-${index}`, + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload(`big-${index}`, { + agentType: agentTypes[index % agentTypes.length], + notes: largeText, + }), + sortIndex: index, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + } + }); + const lineupBytes = await t.run(async (ctx) => + (await ctx.db.query("lineups").collect()) + .map((row) => getConvexSize(row as any)) + .reduce((a, b) => a + b, 0), + ); + expect(lineupBytes).toBeGreaterThan(16 * 1024 * 1024); + + const refreshRead = await t.run(async (ctx) => { + const counted = countReads(ctx); + await refreshStrategyAgentSummary(counted.ctx, strategyId); + return counted.bytesRead(); + }); + expect(refreshRead).toBeLessThan(64 * 1024); + const summary = async () => + await t.run(async (ctx) => { + const row = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategyId)) + .unique(); + return row?.agentTypes; + }); + // 9 sova origins, 8 kayo, 8 viper. + expect(await summary()).toEqual(["sova", "kayo", "viper"]); + + // A lineup edit, which refreshes the summary, reads the lineup it + // edits and not the others. + const batchRead = await owner.run(async (ctx) => { + const counted = countReads(ctx); + await (ops.applyBatch as any)._handler(counted.ctx, { + ...protocol, + strategyPublicId, + clientId: "big-editor", + ops: [ + { + opId: "big-0-to-jett", + type: "lineup.patch", + lineupPublicId: "big-0", + pagePublicId, + payload: oneLineupPayload("big-0", { + agentType: "jett", + notes: largeText, + }), + expectedLineupRevision: 1, + }, + ], + }); + return counted.bytesRead(); + }); + expect(batchRead).toBeLessThan(lineupBytes / 4); + expect(await summary()).toEqual(["kayo", "sova", "viper", "jett"]); + }); + + async function seedLinkImages(t: RootHarness, count: number) { + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + await insertLineup(ctx, { + publicId: "gallery", + strategyId, + pageId, + payloadKind: "lineups", + payloadVersion: 1, + payload: oneLineupPayload("gallery", { + images: Array.from({ length: count }, (_, index) => ({ + id: `gallery-${index}`, + })), + }), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); + } + + test("a strategy showing too many images is refused whole", async () => { + const { t, owner } = await createHarness(); + // Codex's replay copied 2,100 images with 4,210 index reads, past + // Convex's 4,096. + await seedLinkImages(t, 2100); + + const error = await owner + .mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "gallery-copy", + name: "Copy", + }) + .then( + () => null, + (caught: unknown) => caught, + ); + expect(errorCode(error)).toBe("STRATEGY_TOO_LARGE_TO_DUPLICATE"); + expect(await strategyExists(t, "gallery-copy")).toBe(false); + }); + + test("a strategy showing hundreds of images copies within the index read limit", async () => { + const { t, owner } = await createHarness(); + await seedLinkImages(t, 500); + + const rangeReads = await owner.run(async (ctx) => { + const counted = countReads(ctx); + await (strategies.duplicate as any)._handler(counted.ctx, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "gallery-copy", + name: "Copy", + }); + return counted.rangeReads(); + }); + + expect(rangeReads).toBeLessThan(4096); + const copyReferences = await t.run(async (ctx) => { + const copy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "gallery-copy")) + .unique(); + return await ctx.db + .query("assetReferences") + .withIndex("by_strategyId_and_deleted", (q) => + q.eq("strategyId", copy!._id).eq("deleted", false), + ) + .collect(); + }); + expect(copyReferences).toHaveLength(500); + }); + + test("a strategy with thousands of pages is refused whole", async () => { + const { t, owner } = await createHarness(); + const { strategyId } = await ids(t); + // Codex's replay: 4,251 small pages blew the limits before the budget + // existed. + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 4251; index++) { + await ctx.db.insert("pages", { + publicId: `extra-page-${index}`, + strategyId, + name: `Page ${index}`, + sortIndex: index + 1, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + }); + } + }); + + const error = await owner + .mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "pages-copy", + name: "Copy", + }) + .then( + () => null, + (caught: unknown) => caught, + ); + expect(errorCode(error)).toBe("STRATEGY_TOO_LARGE_TO_DUPLICATE"); + expect(await strategyExists(t, "pages-copy")).toBe(false); + }); + + test("a strategy over the row limit is refused whole", async () => { + const { t, owner } = await createHarness(); + const { strategyId, pageId } = await ids(t); + await t.run(async (ctx) => { + const now = Date.now(); + for (let index = 0; index < 4001; index++) { + await ctx.db.insert("elements", { + publicId: `tiny-${index}`, + strategyId, + pageId, + elementType: "text", + payloadKind: "text", + payloadVersion: 1, + payload: { + kind: "text", + payloadVersion: 1, + data: { id: `tiny-${index}`, elementType: "text", text: "" }, + }, + sortIndex: index, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + } + }); + + const error = await owner + .mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "too-many-copy", + name: "Copy", + }) + .then( + () => null, + (caught: unknown) => caught, + ); + expect(errorCode(error)).toBe("STRATEGY_TOO_LARGE_TO_DUPLICATE"); + expect(await strategyExists(t, "too-many-copy")).toBe(false); + }); +}); diff --git a/convex/ops.ts b/convex/ops.ts new file mode 100644 index 00000000..f871506b --- /dev/null +++ b/convex/ops.ts @@ -0,0 +1,1802 @@ +import { mutation, type MutationCtx } from "./_generated/server"; +import { ConvexError, v, type Infer } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { assertCallerIsAccount, assertStrategyRole } from "./lib/auth"; +import { + refreshStrategyAgentSummary, + syncLineupAgents, +} from "./lib/strategyAgentSummary"; +import { + expectAssets, + referencedAssetIds, + staleUploadAgeMs, +} from "./lib/imageAssets"; +import { + removeUploadPlaceholders, + syncElementAssetReferences, + syncLineupAssetReferences, +} from "./lib/assetReferences"; +import { + clampPageIndex, + getStrategyByPublicId, + isTrashed, + listLivePages, + sortByNumberField, + trashPage, +} from "./lib/entities"; +import { + applyBatchResultValidator, + currentOpSnapshotValidator, + operationResultValidator, + strategyOpValidator, + type StrategyOp as WireStrategyOp, +} from "./lib/opTypes"; +import { + assertSupportedCloudProtocol, + CLOUD_OPERATION_TOO_LARGE_MESSAGE, + cloudOperationExceedsPolicy, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { valuesEqual } from "./lib/canonicalValues"; +import { assertLineupGroupAlone, syncLineupItems } from "./lib/lineupItems"; +import { LINEUPS_PAYLOAD_VERSION } from "./lib/payloadValidators"; +import { + errorWithCode, + invalidPayloadError, + pageDeletedError, +} from "./lib/errors"; + +type ElementPayload = Doc<"elements">["payload"]; +type LineupPayload = Doc<"lineups">["payload"]; +type StrategyPatchPayload = { + name?: string; + mapData?: string; + themeProfileId?: string; + clearThemeProfileId?: boolean; + themeOverridePalette?: Doc<"strategies">["themeOverridePalette"]; + clearThemeOverridePalette?: boolean; +}; +type PagePayload = { + name?: string; + isAutoNamed?: boolean; + settings?: Doc<"pageContents">["settings"]; + isAttack?: boolean; +}; +type StrategyOp = { + opId: string; + type: WireStrategyOp["type"]; + kind: "add" | "patch" | "delete" | "reorder"; + entityType: "strategy" | "page" | "pageContent" | "element" | "lineup"; + entityPublicId?: string; + pagePublicId?: string; + payload?: unknown; + sortIndex?: number; + expectedRevision?: number; +}; +type TargetSnapshot = { + revision: number; + payload: unknown; +}; +type OperationResult = { + status: "ack" | "reject" | "failed"; + reason?: string; + appliedRevision?: number; + latestRevision?: number; + latestPayload?: unknown; + code?: string; + rawCode?: string; + message?: string; + eventPageId?: Id<"pages">; +}; + +type CurrentTarget = StrategyOp["entityType"]; +type PublicOperationResult = Infer; + +function normalizeOp(op: WireStrategyOp): StrategyOp { + switch (op.type) { + case "strategy.patch": + return { + opId: op.opId, + type: op.type, + kind: "patch", + entityType: "strategy", + payload: op.payload, + expectedRevision: op.expectedStrategyRevision, + }; + case "page.add": + return { + opId: op.opId, + type: op.type, + kind: "add", + entityType: "page", + entityPublicId: op.pagePublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + sortIndex: op.sortIndex, + expectedRevision: op.expectedStrategyRevision, + }; + case "page.patch": + return { + opId: op.opId, + type: op.type, + kind: "patch", + entityType: "page", + entityPublicId: op.pagePublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + expectedRevision: op.expectedPageRevision, + }; + case "page.delete": + return { + opId: op.opId, + type: op.type, + kind: "delete", + entityType: "page", + entityPublicId: op.pagePublicId, + pagePublicId: op.pagePublicId, + expectedRevision: op.expectedStrategyRevision, + }; + case "page.reorder": + return { + opId: op.opId, + type: op.type, + kind: "reorder", + entityType: "page", + entityPublicId: op.pagePublicId, + pagePublicId: op.pagePublicId, + sortIndex: op.sortIndex, + expectedRevision: op.expectedStrategyRevision, + }; + case "pageContent.patch": + return { + opId: op.opId, + type: op.type, + kind: "patch", + entityType: "pageContent", + entityPublicId: op.pagePublicId, + pagePublicId: op.pagePublicId, + payload: { settings: op.settings }, + expectedRevision: op.expectedPageContentRevision, + }; + case "element.add": + return { + opId: op.opId, + type: op.type, + kind: "add", + entityType: "element", + entityPublicId: op.elementPublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + sortIndex: op.sortIndex, + expectedRevision: op.expectedElementRevision, + }; + case "element.patch": + return { + opId: op.opId, + type: op.type, + kind: "patch", + entityType: "element", + entityPublicId: op.elementPublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + sortIndex: op.sortIndex, + expectedRevision: op.expectedElementRevision, + }; + case "element.delete": + return { + opId: op.opId, + type: op.type, + kind: "delete", + entityType: "element", + entityPublicId: op.elementPublicId, + pagePublicId: op.pagePublicId, + expectedRevision: op.expectedElementRevision, + }; + case "element.reorder": + return { + opId: op.opId, + type: op.type, + kind: "reorder", + entityType: "element", + entityPublicId: op.elementPublicId, + pagePublicId: op.pagePublicId, + sortIndex: op.sortIndex, + expectedRevision: op.expectedElementRevision, + }; + case "lineup.add": + return { + opId: op.opId, + type: op.type, + kind: "add", + entityType: "lineup", + entityPublicId: op.lineupPublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + sortIndex: op.sortIndex, + expectedRevision: op.expectedLineupRevision, + }; + case "lineup.patch": + return { + opId: op.opId, + type: op.type, + kind: "patch", + entityType: "lineup", + entityPublicId: op.lineupPublicId, + pagePublicId: op.pagePublicId, + payload: op.payload, + sortIndex: op.sortIndex, + expectedRevision: op.expectedLineupRevision, + }; + case "lineup.delete": + return { + opId: op.opId, + type: op.type, + kind: "delete", + entityType: "lineup", + entityPublicId: op.lineupPublicId, + pagePublicId: op.pagePublicId, + expectedRevision: op.expectedLineupRevision, + }; + case "lineup.reorder": + return { + opId: op.opId, + type: op.type, + kind: "reorder", + entityType: "lineup", + entityPublicId: op.lineupPublicId, + pagePublicId: op.pagePublicId, + sortIndex: op.sortIndex, + expectedRevision: op.expectedLineupRevision, + }; + } +} + +function isRecord(payload: unknown): payload is Record { + return ( + typeof payload === "object" && payload !== null && !Array.isArray(payload) + ); +} + +function assertKnownPayloadKeys( + payload: Record, + allowedKeys: Set, + label: string, +): void { + for (const key of Object.keys(payload)) { + if (!allowedKeys.has(key)) { + throw invalidPayloadError(`Invalid ${label} payload`); + } + } +} + +const strategyPatchPayloadKeys = new Set([ + "name", + "mapData", + "themeProfileId", + "clearThemeProfileId", + "themeOverridePalette", + "clearThemeOverridePalette", +]); +const pagePayloadKeys = new Set([ + "name", + "isAutoNamed", + "settings", + "isAttack", +]); + +function assertStrategyPatchPayload(payload: unknown): StrategyPatchPayload { + if (payload === undefined) return {}; + if (!isRecord(payload)) throw invalidPayloadError("Invalid strategy payload"); + assertKnownPayloadKeys(payload, strategyPatchPayloadKeys, "strategy"); + return payload as StrategyPatchPayload; +} + +function assertPagePayload(payload: unknown): PagePayload { + if (payload === undefined) return {}; + if (!isRecord(payload)) throw invalidPayloadError("Invalid page payload"); + assertKnownPayloadKeys(payload, pagePayloadKeys, "page"); + return payload as PagePayload; +} + +function assertElementPayload(payload: unknown): ElementPayload { + if (!isRecord(payload)) { + throw errorWithCode("MISSING_ELEMENT_PAYLOAD", "Missing element payload"); + } + const kind = payload.kind; + if ( + kind !== "agent" && + kind !== "ability" && + kind !== "drawing" && + kind !== "text" && + kind !== "image" && + kind !== "utility" + ) { + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_KIND", + "Invalid element payload kind", + ); + } + if (typeof payload.payloadVersion !== "number") { + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_VERSION", + "Invalid element payload version", + ); + } + if (!isRecord(payload.data)) { + throw errorWithCode( + "INVALID_ELEMENT_PAYLOAD_DATA", + "Invalid element payload data", + ); + } + if ( + typeof payload.data.elementType === "string" && + payload.data.elementType !== kind + ) { + throw errorWithCode( + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "elementType_payloadKind_mismatch", + ); + } + return payload as ElementPayload; +} + +function invalidLineupData(message: string) { + return errorWithCode("INVALID_LINEUP_PAYLOAD_DATA", message); +} + +/// Checks one lineup group row before it is stored: a group keyed by its +/// id, whose origins, landings and links each have an id unique among their +/// kind, each origin placing an agent and each landing an ability, with at +/// least one link, and every link joining an origin and a landing the row +/// holds. A row that breaks any of these is refused whole, never stored. +/// A spot no link uses is allowed. +function assertLineupPayload( + payload: unknown, + lineupPublicId: string, +): LineupPayload { + if (!isRecord(payload)) { + throw errorWithCode("MISSING_LINEUP_PAYLOAD", "Missing lineup payload"); + } + // Argument validation lets the graph rows of protocol 4 through, so an old + // client reaches the protocol gate (see lineupOpPayloadValidator); none is + // ever stored. + if (payload.kind !== "lineups") { + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_KIND", + "Invalid lineup payload kind", + ); + } + if (payload.payloadVersion !== LINEUPS_PAYLOAD_VERSION) { + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_VERSION", + "Invalid lineup payload version", + ); + } + if (!isRecord(payload.data)) { + throw errorWithCode( + "INVALID_LINEUP_PAYLOAD_DATA", + "Invalid lineup payload data", + ); + } + const data = payload.data; + if (typeof data.id !== "string" || data.id.length === 0) { + throw invalidLineupData("Lineup group has no id"); + } + if (lineupPublicId !== data.id) { + throw invalidLineupData("Lineup group key does not match its payload"); + } + const originIds = idsOfEntries(data.origins, "origins", "agent"); + const landingIds = idsOfEntries(data.landings, "landings", "ability"); + idsOfEntries(data.links, "links"); + const links = data.links as Array>; + // A group without a lineup would load as nothing and read as a deletion + // nobody made. + if (links.length === 0) { + throw invalidLineupData("Lineup group has no lineups"); + } + for (const link of links) { + if ( + typeof link.originId !== "string" || + !originIds.has(link.originId) || + typeof link.landingId !== "string" || + !landingIds.has(link.landingId) + ) { + throw invalidLineupData( + "A lineup names an origin or landing its group does not hold", + ); + } + } + return payload as LineupPayload; +} + +/// The ids of a lineup group's [field] (its origins, landings or links), +/// refusing the row unless [entries] is a list of objects each with an id +/// no other entry in it shares, and, given a [markerKey], the object it +/// places there. +function idsOfEntries( + entries: unknown, + field: string, + markerKey?: "agent" | "ability", +): Set { + if (!Array.isArray(entries)) { + throw invalidLineupData(`Lineup group ${field} is not a list`); + } + const ids = new Set(); + for (const entry of entries) { + if ( + !isRecord(entry) || + typeof entry.id !== "string" || + entry.id.length === 0 + ) { + throw invalidLineupData(`Lineup group ${field} has an entry with no id`); + } + if (ids.has(entry.id)) { + throw invalidLineupData(`Two of a lineup group's ${field} share an id`); + } + if (markerKey !== undefined && !isRecord(entry[markerKey])) { + throw invalidLineupData( + `Lineup group ${field} has an entry with no ${markerKey}`, + ); + } + ids.add(entry.id); + } + return ids; +} + +/// A patch or reorder of a row a teammate deleted. Changing the tombstone +/// would ack an edit nobody sees, so it is refused; an add expecting the +/// tombstone's revision brings the row back instead ("Keep mine"). +function refuseChangeToDeleted( + row: Doc<"elements"> | Doc<"lineups">, +): OperationResult | null { + if (!row.deleted) return null; + return rejected( + "deleted", + { revision: row.revision, payload: row.payload }, + row.pageId, + ); +} + +function setIfChanged( + patch: Record, + key: string, + currentValue: unknown, + nextValue: unknown, +): void { + if (!valuesEqual(currentValue, nextValue)) patch[key] = nextValue; +} + +function requireExpectedRevision(op: StrategyOp, currentRevision: number) { + if (op.expectedRevision === undefined) { + return { status: "reject" as const, reason: "missing_expected_revision" }; + } + if (op.expectedRevision !== currentRevision) { + return { status: "reject" as const, reason: "revision_mismatch" }; + } + return null; +} + +function strategyPayload(strategy: Doc<"strategies">) { + return { + name: strategy.name, + mapData: strategy.mapData, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + }; +} + +function pagePayload(page: Doc<"pages">) { + return { + name: page.name, + ...(page.isAutoNamed === undefined + ? {} + : { isAutoNamed: page.isAutoNamed }), + isAttack: page.isAttack, + sortIndex: page.sortIndex, + }; +} + +async function getPageByPublicIdOrNull( + ctx: MutationCtx, + publicId: string, +): Promise | null> { + return await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(); +} + +/// Whether content on [pageId] can change: not on a page in the trash, nor +/// on one an older delete removed whose rows wait to be purged. +async function contentPageIsLive( + ctx: MutationCtx, + pageId: Id<"pages">, +): Promise { + const page = await ctx.db.get(pageId); + return page !== null && !isTrashed(page); +} + +/// Refuses a change to content that is not on a live page. A change refused +/// while its page is in the trash lands if the page is restored and it is +/// sent again. +async function assertContentPageLive( + ctx: MutationCtx, + pageId: Id<"pages">, +): Promise { + if (!(await contentPageIsLive(ctx, pageId))) throw pageDeletedError(); +} + +/// A delete of content on a page that is not live. A client that can +/// restore pages is refused, so the delete is sent again once the page is +/// back. Older clients get the no-op they got when a deleted page's content +/// was purged: they cannot restore it, and have nothing left to delete. +async function refuseDeleteOffLivePage( + ctx: MutationCtx, + row: Doc<"elements"> | Doc<"lineups">, + checkTrashedPageDeletes: boolean, +): Promise { + if (await contentPageIsLive(ctx, row.pageId)) return null; + if (checkTrashedPageDeletes) throw pageDeletedError(); + return noop(row.revision, row.pageId); +} + +/// The page a content op names to add to or move to: refused if it is not +/// this strategy's, or is in the trash. +async function requireTargetPage( + ctx: MutationCtx, + strategy: Doc<"strategies">, + pagePublicId: string, +): Promise> { + const page = await getPageByPublicIdOrNull(ctx, pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + if (isTrashed(page)) throw pageDeletedError(); + return page; +} + +async function getElementByPublicIdOrNull( + ctx: MutationCtx, + publicId: string, +): Promise | null> { + return await ctx.db + .query("elements") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(); +} + +/// A lineup row key is unique within its strategy only: a strategy copied +/// on a device keeps its original's lineup ids, so both upload the same keys. +async function getLineupByPublicIdOrNull( + ctx: MutationCtx, + strategyId: Id<"strategies">, + publicId: string, +): Promise | null> { + return await ctx.db + .query("lineups") + .withIndex("by_strategyId_and_publicId", (q) => + q.eq("strategyId", strategyId).eq("publicId", publicId), + ) + .unique(); +} + +async function getPageContent( + ctx: MutationCtx, + pageId: Id<"pages">, +): Promise> { + const rows = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .take(2); + if (rows.length !== 1) { + throw errorWithCode( + "INVALID_PAGE_CONTENT_COUNT", + "Each page must have exactly one page content row", + ); + } + return rows[0]!; +} + +async function getTargetSnapshot( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + if ( + op.entityType === "strategy" || + (op.entityType === "page" && op.kind !== "patch") + ) { + return { revision: strategy.revision, payload: strategyPayload(strategy) }; + } + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) return null; + if (op.entityType === "page") { + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id || isTrashed(page)) { + return null; + } + return { revision: page.revision, payload: pagePayload(page) }; + } + if (op.entityType === "pageContent") { + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id || isTrashed(page)) { + return null; + } + const content = await getPageContent(ctx, page._id); + return { + revision: content.revision, + payload: { settings: content.settings ?? null }, + }; + } + // Content on a page in the trash is not the server's current copy. + if (op.entityType === "element") { + const element = await getElementByPublicIdOrNull(ctx, publicId); + if ( + element === null || + element.strategyId !== strategy._id || + !(await contentPageIsLive(ctx, element.pageId)) + ) { + return null; + } + return { revision: element.revision, payload: element.payload }; + } + const lineup = await getLineupByPublicIdOrNull(ctx, strategy._id, publicId); + if (lineup === null || !(await contentPageIsLive(ctx, lineup.pageId))) { + return null; + } + return { revision: lineup.revision, payload: lineup.payload }; +} + +function rejected( + reason: string, + snapshot?: TargetSnapshot | null, + eventPageId?: Id<"pages">, +): OperationResult { + return { + status: "reject", + reason, + latestRevision: snapshot?.revision, + latestPayload: snapshot?.payload, + eventPageId, + }; +} + +function noop(revision?: number, eventPageId?: Id<"pages">): OperationResult { + return { + status: "ack", + reason: "noop", + appliedRevision: revision, + latestRevision: revision, + eventPageId, + }; +} + +function currentTargetForOp(op: StrategyOp): CurrentTarget { + if ( + op.entityType === "page" && + (op.kind === "add" || op.kind === "delete" || op.kind === "reorder") + ) { + return "strategy"; + } + return op.entityType; +} + +function isRejectionReason( + reason: string, +): reason is Extract["reason"] { + return ( + reason === "already_exists" || + reason === "deleted" || + reason === "element_strategy_mismatch" || + reason === "lineup_strategy_mismatch" || + reason === "missing_expected_revision" || + reason === "not_found" || + reason === "page_strategy_mismatch" || + reason === "revision_mismatch" + ); +} + +function toPublicResult( + op: StrategyOp, + result: OperationResult, +): PublicOperationResult { + if (result.status === "failed") { + return { + opId: op.opId, + status: "failed", + code: result.code ?? "INTERNAL_ERROR", + rawCode: result.rawCode ?? "INTERNAL_ERROR", + message: result.message ?? "Unexpected Convex function failure", + }; + } + if (result.status === "ack" && result.reason === "noop") { + return { + opId: op.opId, + status: "noop", + ...(result.appliedRevision === undefined + ? {} + : { currentRevision: result.appliedRevision }), + }; + } + if (result.status === "ack") { + if (result.appliedRevision === undefined) { + throw new Error(`Applied op ${op.opId} did not return a revision`); + } + return { + opId: op.opId, + status: "applied", + appliedRevision: result.appliedRevision, + }; + } + const reason = result.reason ?? "not_found"; + if (!isRejectionReason(reason)) { + throw new Error(`Unknown op rejection reason: ${reason}`); + } + return { + opId: op.opId, + status: "rejected", + reason, + ...(result.latestRevision === undefined || result.latestPayload === undefined + ? {} + : { + current: { + type: currentTargetForOp(op), + revision: result.latestRevision, + value: result.latestPayload, + } as Infer, + }), + }; +} + +async function applyStrategyOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise<{ strategy: Doc<"strategies">; result: OperationResult }> { + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported strategy op"); + } + const payload = assertStrategyPatchPayload(op.payload); + const patch: Record = {}; + if (payload.name !== undefined) { + setIfChanged(patch, "name", strategy.name, payload.name); + } + if (payload.mapData !== undefined) { + setIfChanged(patch, "mapData", strategy.mapData, payload.mapData); + } + if (payload.themeProfileId !== undefined) { + setIfChanged( + patch, + "themeProfileId", + strategy.themeProfileId, + payload.themeProfileId, + ); + } + if (payload.clearThemeProfileId === true) { + setIfChanged(patch, "themeProfileId", strategy.themeProfileId, undefined); + } + if (payload.themeOverridePalette !== undefined) { + setIfChanged( + patch, + "themeOverridePalette", + strategy.themeOverridePalette, + payload.themeOverridePalette, + ); + } + if (payload.clearThemeOverridePalette === true) { + setIfChanged( + patch, + "themeOverridePalette", + strategy.themeOverridePalette, + undefined, + ); + } + if (Object.keys(patch).length === 0) { + return { strategy, result: noop(strategy.revision) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected(mismatch.reason, { + revision: strategy.revision, + payload: strategyPayload(strategy), + }), + }; + } + + const revision = strategy.revision + 1; + const updatedAt = Date.now(); + await ctx.db.patch(strategy._id, { ...patch, revision, updatedAt }); + const updated = { + ...strategy, + ...patch, + revision, + updatedAt, + } as Doc<"strategies">; + return { + strategy: updated, + result: { status: "ack", appliedRevision: revision }, + }; +} + +async function applyPageOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, + userId: Id<"users">, +): Promise<{ strategy: Doc<"strategies">; result: OperationResult }> { + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_PAGE_ID", "Missing page id"); + } + const existing = await getPageByPublicIdOrNull(ctx, publicId); + + if (op.kind === "add") { + const payload = assertPagePayload(op.payload); + if (existing !== null) { + if (existing.strategyId !== strategy._id) { + return { strategy, result: rejected("page_strategy_mismatch") }; + } + if (isTrashed(existing)) throw pageDeletedError(); + const content = await getPageContent(ctx, existing._id); + const pages = await listLivePages(ctx, strategy._id); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? 0, + Math.max(0, pages.length - 1), + ); + const identical = + existing.name === (payload.name ?? "Page") && + existing.isAutoNamed === payload.isAutoNamed && + existing.sortIndex === desiredSortIndex && + existing.isAttack === (payload.isAttack ?? true) && + valuesEqual(content.settings, payload.settings); + if (identical) { + return { strategy, result: noop(strategy.revision, existing._id) }; + } + return { + strategy, + result: rejected( + "already_exists", + { revision: strategy.revision, payload: strategyPayload(strategy) }, + existing._id, + ), + }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected(mismatch.reason, { + revision: strategy.revision, + payload: strategyPayload(strategy), + }), + }; + } + + const now = Date.now(); + const pages = await listLivePages(ctx, strategy._id); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? 0, + orderedPages.length, + ); + for (let index = 0; index < orderedPages.length; index += 1) { + const page = orderedPages[index]!; + const normalizedIndex = index >= desiredSortIndex ? index + 1 : index; + const normalizedName = + page.isAutoNamed === true + ? `Page ${normalizedIndex + 1}` + : page.name; + if ( + page.sortIndex !== normalizedIndex || + page.name !== normalizedName + ) { + await ctx.db.patch(page._id, { + name: normalizedName, + sortIndex: normalizedIndex, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const pageId = await ctx.db.insert("pages", { + publicId, + strategyId: strategy._id, + name: payload.name ?? "Page", + ...(payload.isAutoNamed === undefined + ? {} + : { isAutoNamed: payload.isAutoNamed }), + sortIndex: desiredSortIndex, + isAttack: payload.isAttack ?? true, + revision: 1, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("pageContents", { + pageId, + settings: payload.settings, + revision: 1, + createdAt: now, + updatedAt: now, + }); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { status: "ack", appliedRevision: revision, eventPageId: pageId }, + }; + } + + if (op.kind === "delete") { + // Already in the trash counts as deleted. + if ( + existing === null || + existing.strategyId !== strategy._id || + isTrashed(existing) + ) { + return { strategy, result: noop(strategy.revision) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: strategy.revision, payload: strategyPayload(strategy) }, + existing._id, + ), + }; + } + const pages = await listLivePages(ctx, strategy._id); + if (pages.length <= 1) { + throw errorWithCode("INVALID_OP", "Cannot delete last page"); + } + const now = Date.now(); + await trashPage(ctx, existing, pages, userId, now); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return { strategy, result: rejected("not_found") }; + } + if (isTrashed(existing)) throw pageDeletedError(); + if (op.kind === "reorder") { + const pages = await listLivePages(ctx, strategy._id); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const currentIndex = orderedPages.findIndex( + (page) => page._id === existing._id, + ); + const desiredSortIndex = clampPageIndex( + op.sortIndex ?? currentIndex, + Math.max(0, orderedPages.length - 1), + ); + const reorderedPages = orderedPages.filter( + (page) => page._id !== existing._id, + ); + reorderedPages.splice(desiredSortIndex, 0, existing); + const alreadyNormalized = reorderedPages.every( + (page, index) => page.sortIndex === index, + ); + if (currentIndex === desiredSortIndex && alreadyNormalized) { + return { strategy, result: noop(strategy.revision, existing._id) }; + } + const mismatch = requireExpectedRevision(op, strategy.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: strategy.revision, payload: strategyPayload(strategy) }, + existing._id, + ), + }; + } + const now = Date.now(); + for (let index = 0; index < reorderedPages.length; index += 1) { + const page = reorderedPages[index]!; + const normalizedName = + page.isAutoNamed === true ? `Page ${index + 1}` : page.name; + if (page.sortIndex !== index || page.name !== normalizedName) { + await ctx.db.patch(page._id, { + name: normalizedName, + sortIndex: index, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { + strategy: { ...strategy, revision, updatedAt: now }, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; + } + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported page op"); + } + const payload = assertPagePayload(op.payload); + if (payload.settings !== undefined) { + throw errorWithCode( + "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + "Page settings require a pageContent operation", + ); + } + const patch: Record = {}; + if (payload.name !== undefined) { + setIfChanged(patch, "name", existing.name, payload.name); + } + if (payload.isAutoNamed !== undefined) { + setIfChanged( + patch, + "isAutoNamed", + existing.isAutoNamed, + payload.isAutoNamed, + ); + } + if (payload.isAttack !== undefined) { + setIfChanged(patch, "isAttack", existing.isAttack, payload.isAttack); + } + if (Object.keys(patch).length === 0) { + return { strategy, result: noop(existing.revision, existing._id) }; + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return { + strategy, + result: rejected( + mismatch.reason, + { revision: existing.revision, payload: pagePayload(existing) }, + existing._id, + ), + }; + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { + strategy, + result: { + status: "ack", + appliedRevision: revision, + eventPageId: existing._id, + }, + }; +} + +async function applyPageContentOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise { + if (op.kind !== "patch") { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported page content op"); + } + const publicId = op.entityPublicId ?? op.pagePublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_PAGE_ID", "Missing page id"); + } + const page = await getPageByPublicIdOrNull(ctx, publicId); + if (page === null || page.strategyId !== strategy._id) { + return rejected("not_found"); + } + if (isTrashed(page)) throw pageDeletedError(); + const payload = assertPagePayload(op.payload); + if (payload.name !== undefined || payload.isAttack !== undefined) { + throw errorWithCode( + "PAGE_DESCRIPTOR_REQUIRES_PAGE_OP", + "Page descriptor fields require a page operation", + ); + } + const content = await getPageContent(ctx, page._id); + if (valuesEqual(content.settings, payload.settings)) { + return noop(content.revision, page._id); + } + const mismatch = requireExpectedRevision(op, content.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { + revision: content.revision, + payload: { settings: content.settings ?? null }, + }, + page._id, + ); + } + const revision = content.revision + 1; + await ctx.db.patch(content._id, { + settings: payload.settings, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId: page._id }; +} + +async function applyElementOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, + checkTrashedPageDeletes: boolean, +): Promise { + const publicId = op.entityPublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); + } + const existing = await getElementByPublicIdOrNull(ctx, publicId); + + if (op.kind === "add") { + if (op.pagePublicId === undefined) { + throw errorWithCode("MISSING_PAGE_PUBLIC_ID", "Missing pagePublicId"); + } + const page = await requireTargetPage(ctx, strategy, op.pagePublicId); + const payload = assertElementPayload(op.payload); + if (existing !== null) { + if (existing.strategyId !== strategy._id) { + return rejected("element_strategy_mismatch"); + } + await assertContentPageLive(ctx, existing.pageId); + if (existing.deleted) { + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + pageId: page._id, + elementType: payload.kind, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision, + deleted: false, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: page._id, + }; + } + const identical = + existing.pageId === page._id && + existing.elementType === payload.kind && + valuesEqual(existing.payload, payload) && + existing.sortIndex === (op.sortIndex ?? 0) && + existing.deleted === false; + if (identical) return noop(existing.revision, existing.pageId); + return rejected( + "already_exists", + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const now = Date.now(); + await ctx.db.insert("elements", { + publicId, + strategyId: strategy._id, + pageId: page._id, + elementType: payload.kind, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + return { status: "ack", appliedRevision: 1, eventPageId: page._id }; + } + + if (op.kind === "delete") { + if (existing === null || existing.strategyId !== strategy._id) + return noop(); + if (existing.deleted) return noop(existing.revision, existing.pageId); + const offLivePage = await refuseDeleteOffLivePage( + ctx, + existing, + checkTrashedPageDeletes, + ); + if (offLivePage !== null) return offLivePage; + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + deleted: true, + revision, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: existing.pageId, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return rejected("not_found"); + } + await assertContentPageLive(ctx, existing.pageId); + const deleted = refuseChangeToDeleted(existing); + if (deleted !== null) return deleted; + const patch: Record = {}; + let eventPageId = existing.pageId; + if (op.kind === "patch") { + if (op.payload !== undefined) { + const payload = assertElementPayload(op.payload); + if (payload.kind !== existing.elementType) { + throw errorWithCode( + "ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH", + "elementType_payloadKind_mismatch", + ); + } + setIfChanged(patch, "payload", existing.payload, payload); + setIfChanged(patch, "payloadKind", existing.payloadKind, payload.kind); + setIfChanged( + patch, + "payloadVersion", + existing.payloadVersion, + payload.payloadVersion, + ); + } + if (op.sortIndex !== undefined) { + setIfChanged(patch, "sortIndex", existing.sortIndex, op.sortIndex); + } + if (op.pagePublicId !== undefined) { + const page = await requireTargetPage(ctx, strategy, op.pagePublicId); + setIfChanged(patch, "pageId", existing.pageId, page._id); + eventPageId = page._id; + } + } else if (op.kind === "reorder") { + setIfChanged( + patch, + "sortIndex", + existing.sortIndex, + op.sortIndex ?? existing.sortIndex, + ); + } else { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported element op"); + } + if (Object.keys(patch).length === 0) { + return noop(existing.revision, eventPageId); + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId }; +} + +async function applyLineupOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, + checkTrashedPageDeletes: boolean, +): Promise { + const publicId = op.entityPublicId; + if (publicId === undefined) { + throw errorWithCode("MISSING_ENTITY_PUBLIC_ID", "Missing entityPublicId"); + } + const existing = await getLineupByPublicIdOrNull( + ctx, + strategy._id, + publicId, + ); + + if (op.kind === "add") { + if (op.pagePublicId === undefined) { + throw errorWithCode("MISSING_PAGE_PUBLIC_ID", "Missing pagePublicId"); + } + const page = await requireTargetPage(ctx, strategy, op.pagePublicId); + const payload = assertLineupPayload(op.payload, publicId); + if (existing !== null) { + await assertContentPageLive(ctx, existing.pageId); + if (existing.deleted) { + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + await assertLineupGroupAlone(ctx, page._id, existing._id, payload); + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + pageId: page._id, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision, + deleted: false, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: page._id, + }; + } + // The row already holds exactly this lineup: a retry landed twice, or + // two devices uploaded the same copied strategy. Order is not part of + // a lineup's content (each client places new rows after the highest it + // knows), so a different sortIndex alone is still the same add. + const identical = + existing.pageId === page._id && valuesEqual(existing.payload, payload); + if (identical) return noop(existing.revision, existing.pageId); + return rejected( + "already_exists", + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + await assertLineupGroupAlone(ctx, page._id, null, payload); + const now = Date.now(); + await ctx.db.insert("lineups", { + publicId, + strategyId: strategy._id, + pageId: page._id, + payloadKind: payload.kind, + payloadVersion: payload.payloadVersion, + payload, + sortIndex: op.sortIndex ?? 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + return { status: "ack", appliedRevision: 1, eventPageId: page._id }; + } + + if (op.kind === "delete") { + if (existing === null || existing.strategyId !== strategy._id) + return noop(); + if (existing.deleted) return noop(existing.revision, existing.pageId); + const offLivePage = await refuseDeleteOffLivePage( + ctx, + existing, + checkTrashedPageDeletes, + ); + if (offLivePage !== null) return offLivePage; + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + deleted: true, + revision, + updatedAt: Date.now(), + }); + return { + status: "ack", + appliedRevision: revision, + eventPageId: existing.pageId, + }; + } + + if (existing === null || existing.strategyId !== strategy._id) { + return rejected("not_found"); + } + await assertContentPageLive(ctx, existing.pageId); + const deleted = refuseChangeToDeleted(existing); + if (deleted !== null) return deleted; + const patch: Record = {}; + let eventPageId = existing.pageId; + if (op.kind === "patch") { + if (op.payload !== undefined) { + const payload = assertLineupPayload(op.payload, publicId); + setIfChanged(patch, "payload", existing.payload, payload); + setIfChanged(patch, "payloadKind", existing.payloadKind, payload.kind); + setIfChanged( + patch, + "payloadVersion", + existing.payloadVersion, + payload.payloadVersion, + ); + } + if (op.sortIndex !== undefined) { + setIfChanged(patch, "sortIndex", existing.sortIndex, op.sortIndex); + } + if (op.pagePublicId !== undefined) { + const page = await getPageByPublicIdOrNull(ctx, op.pagePublicId); + if (page === null || page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + // A lineup row never moves between pages: no client action does that, + // and a patch naming another page means its key clashed with a row on + // a different page (a rejected add turned into a patch by "Keep mine"). + // Moving it would take the lineup away from the page that shows it, + // so refuse and let the conflict surface instead. + if (page._id !== existing.pageId) { + // The client matches this text (lineupPageMismatchMessage) to + // explain the refusal. + throw errorWithCode( + "LINEUP_PAGE_MISMATCH", + "This lineup belongs to another page and cannot be moved", + ); + } + } + } else if (op.kind === "reorder") { + setIfChanged( + patch, + "sortIndex", + existing.sortIndex, + op.sortIndex ?? existing.sortIndex, + ); + } else { + throw errorWithCode("UNSUPPORTED_OP", "Unsupported lineup op"); + } + if (Object.keys(patch).length === 0) { + return noop(existing.revision, eventPageId); + } + const mismatch = requireExpectedRevision(op, existing.revision); + if (mismatch !== null) { + return rejected( + mismatch.reason, + { revision: existing.revision, payload: existing.payload }, + existing.pageId, + ); + } + if (patch.payload !== undefined) { + await assertLineupGroupAlone( + ctx, + existing.pageId, + existing._id, + patch.payload as LineupPayload, + ); + } + const revision = existing.revision + 1; + await ctx.db.patch(existing._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { status: "ack", appliedRevision: revision, eventPageId }; +} + +function isAgentRow(row: Doc<"elements"> | Doc<"lineups"> | null): boolean { + return row !== null && "elementType" in row && row.elementType === "agent"; +} + +/// The element or lineup row an op targets in this strategy, if any. +async function contentRowForOp( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, +): Promise | Doc<"lineups"> | null> { + const publicId = op.entityPublicId; + if (publicId === undefined) return null; + const row = + op.entityType === "element" + ? await getElementByPublicIdOrNull(ctx, publicId) + : op.entityType === "lineup" + ? await getLineupByPublicIdOrNull(ctx, strategy._id, publicId) + : null; + return row !== null && row.strategyId === strategy._id ? row : null; +} + +/// Keeps asset rows in step with an accepted content op. An image the row +/// newly shows gets a placeholder if nothing has been uploaded for it yet, so +/// a slow upload reads as on its way rather than missing, and a duplicate +/// waits for it. An image a deleted element showed is added to +/// [placeholderCandidates]; applyBatch drops those placeholders after the +/// whole batch, if nothing shows the image by then. +/// +/// Restoring a deleted element (undo) usually finds its asset row still +/// there, since deleting an element leaves its asset alone. With no row, an +/// element restored within the stale-upload window may still have its upload +/// coming, so it gets a placeholder like a new one. One first placed longer +/// ago would have uploaded or been swept by now, so it does not: a +/// placeholder there could only spin for another day before reading as +/// unavailable. +async function reconcileExpectedAssets( + ctx: MutationCtx, + strategy: Doc<"strategies">, + op: StrategyOp, + rowBefore: Doc<"elements"> | Doc<"lineups"> | null, + placeholderCandidates: Set, +): Promise { + const row = await contentRowForOp(ctx, strategy, op); + if (row === null) return; + // Keep the row's image references in step with it (media cleanup reads + // those, not the content). + if ("elementType" in row) { + await syncElementAssetReferences(ctx, row._id, row); + } else { + await syncLineupAssetReferences(ctx, row._id, row); + await syncLineupAgents(ctx, row._id, row); + await syncLineupItems(ctx, row._id, row); + } + const now = Date.now(); + const assetsBefore = referencedAssetIds(rowBefore); + const assetsAfter = referencedAssetIds(row); + const restoredAfterUploadWindow = + op.entityType === "element" && + rowBefore?.deleted === true && + !row.deleted && + now - row.createdAt >= staleUploadAgeMs; + if (!restoredAfterUploadWindow) { + await expectAssets( + ctx, + strategy._id, + [...assetsAfter].filter((id) => !assetsBefore.has(id)), + now, + ); + } + if (op.entityType === "element") { + // Only nominated here; removed once the whole batch has applied (see + // applyBatch), since a later op in the batch may show the image again. + for (const id of assetsBefore) { + if (!assetsAfter.has(id)) placeholderCandidates.add(id); + } + } +} + +export const applyBatch = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + clientId: v.string(), + ops: v.array(strategyOpValidator), + // Set by clients that can restore a deleted page (pages:restore): a + // delete of content on a page in the trash is refused, to be sent again + // once the page is back. Older clients get the no-op a deleted page's + // purged content gave them (see refuseDeleteOffLivePage). + checkTrashedPageDeletes: v.optional(v.boolean()), + // Sent by clients on protocol 4, which stored lineups as origin, + // landing and link rows. Ignored: accepting them lets such a client + // reach the protocol gate (CLIENT_UPGRADE_REQUIRED) instead of failing + // argument validation. + checkLineupLinkEnds: v.optional(v.boolean()), + checkLineupEndDeletes: v.optional(v.boolean()), + // Set by clients that bind a batch to the account whose outbox holds + // it: that account's identity subject. A batch can reach the server + // under a different sign-in than the one that queued it (the transport + // resends a pending mutation after a new sign-in), so one bound to + // another account is refused whole (see assertCallerIsAccount). Older + // clients send it unbound, checked against whoever is signed in. + accountSubject: v.optional(v.string()), + }, + returns: applyBatchResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + if (args.accountSubject !== undefined) { + await assertCallerIsAccount(ctx, args.accountSubject); + } + let strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user } = await assertStrategyRole(ctx, strategy, "editor"); + const results: PublicOperationResult[] = []; + let acceptedStrategyBatchBaseRevision: number | undefined; + // Whether an accepted op may have changed which agents the strategy + // uses: a page added, moved to or out of the trash, an agent element, + // or a lineup. + let agentsMayHaveChanged = false; + // Images deleted elements showed, whose upload placeholders may go once + // the whole batch has applied and nothing shows them any more. + const placeholderCandidates = new Set(); + + // Outcomes are per operation: accepted changes and visible rejections are + // committed together by this single Convex transaction. One stale op must + // not erase an independent op that the server already accepted. + for (const rawOp of args.ops) { + let op = normalizeOp(rawOp); + const existingEvent = await ctx.db + .query("operationEvents") + .withIndex("by_strategyId_clientId_opId", (q) => + q + .eq("strategyId", strategy._id) + .eq("clientId", args.clientId) + .eq("opId", op.opId), + ) + .first(); + if (existingEvent !== null) { + const latest = await getTargetSnapshot(ctx, strategy, op); + // An accepted op replays at the revision it landed at, not the + // row's latest: a successor rebased onto a teammate's later edit + // would overwrite that edit unseen. An event that recorded no + // revision replays with none, for the same reason; the client then + // keeps its base revision and a later edit meets the usual check. + const replayResult: OperationResult = + existingEvent.status === "failed" + ? { + status: "failed", + code: existingEvent.code, + rawCode: existingEvent.rawCode, + message: existingEvent.message, + } + : existingEvent.status === "rejected" + ? { + status: "reject", + reason: existingEvent.reason, + latestRevision: latest?.revision, + latestPayload: latest?.payload, + } + : noop(existingEvent.appliedRevision); + results.push(toPublicResult(op, replayResult)); + continue; + } + + const originalExpectedRevision = op.expectedRevision; + const targetsStrategyRevision = currentTargetForOp(op) === "strategy"; + const strategyRevisionBefore = strategy.revision; + if ( + targetsStrategyRevision && + acceptedStrategyBatchBaseRevision !== undefined && + originalExpectedRevision === acceptedStrategyBatchBaseRevision + ) { + // Offline clients can queue several independent page structure edits + // from one strategy snapshot. Once the first matching op advances the + // strategy in this transaction, chain its same-base siblings onto the + // revision produced by the preceding op. A stale first op never opens + // this path, so another client's revision still rejects the full batch. + op = { ...op, expectedRevision: strategy.revision }; + } + + const rowBefore = await contentRowForOp(ctx, strategy, op); + + let result: OperationResult; + if (cloudOperationExceedsPolicy(rawOp)) { + result = { + status: "failed", + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, + }; + } else { + try { + if (op.entityType === "strategy") { + const applied = await applyStrategyOp(ctx, strategy, op); + strategy = applied.strategy; + result = applied.result; + } else if (op.entityType === "page") { + const applied = await applyPageOp(ctx, strategy, op, user._id); + strategy = applied.strategy; + result = applied.result; + } else if (op.entityType === "pageContent") { + result = await applyPageContentOp(ctx, strategy, op); + } else if (op.entityType === "element") { + result = await applyElementOp( + ctx, + strategy, + op, + args.checkTrashedPageDeletes === true, + ); + } else { + result = await applyLineupOp( + ctx, + strategy, + op, + args.checkTrashedPageDeletes === true, + ); + } + if ( + result.status === "ack" && + (op.entityType === "page" || + op.entityType === "lineup" || + (op.entityType === "element" && + (isAgentRow(rowBefore) || + (op.payload as { kind?: unknown } | undefined)?.kind === + "agent"))) + ) { + agentsMayHaveChanged = true; + } + if (result.status === "ack") { + await reconcileExpectedAssets( + ctx, + strategy, + op, + rowBefore, + placeholderCandidates, + ); + } + } catch (error) { + if (!(error instanceof ConvexError)) throw error; + const rawCode = + typeof error.data?.code === "string" + ? error.data.code + : "INTERNAL_ERROR"; + const message = + typeof error.data?.message === "string" + ? error.data.message + : error.message; + result = { + status: "failed", + code: rawCode, + rawCode, + message, + }; + } + } + + if ( + targetsStrategyRevision && + acceptedStrategyBatchBaseRevision === undefined && + originalExpectedRevision !== undefined && + originalExpectedRevision === strategyRevisionBefore && + result.status === "ack" + ) { + acceptedStrategyBatchBaseRevision = originalExpectedRevision; + } + + const publicResult = toPublicResult(op, result); + + await ctx.db.insert("operationEvents", { + strategyId: strategy._id, + pageId: result.eventPageId, + clientId: args.clientId, + opId: op.opId, + opType: op.type, + status: publicResult.status, + reason: + publicResult.status === "rejected" ? publicResult.reason : undefined, + code: publicResult.status === "failed" ? publicResult.code : undefined, + rawCode: + publicResult.status === "failed" ? publicResult.rawCode : undefined, + message: + publicResult.status === "failed" ? publicResult.message : undefined, + expectedRevision: originalExpectedRevision, + appliedRevision: + publicResult.status === "applied" + ? publicResult.appliedRevision + : publicResult.status === "noop" + ? publicResult.currentRevision + : undefined, + createdAt: Date.now(), + }); + results.push(publicResult); + } + + // Checked against the batch's final state, once. + await removeUploadPlaceholders(ctx, strategy._id, placeholderCandidates); + + if (agentsMayHaveChanged) { + await refreshStrategyAgentSummary(ctx, strategy._id); + } + + return { strategyPublicId: strategy.publicId, results }; + }, +}); diff --git a/convex/page.ts b/convex/page.ts new file mode 100644 index 00000000..535a5421 --- /dev/null +++ b/convex/page.ts @@ -0,0 +1,88 @@ +import { query } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc } from "./_generated/dataModel"; +import { assertStrategyReadable } from "./lib/auth"; +import { getPageByPublicId, getStrategyByPublicId } from "./lib/entities"; +import { errorWithCode, internalError } from "./lib/errors"; +import { + collectReferencedAssetIds, + getViewerAssetForStrategy, + serializeAssetForViewer, +} from "./lib/imageAssets"; +import { + serializeElement, + serializeLineup, + serializePageContent, + serializePageDescriptor, +} from "./lib/snapshotSerialization"; +import { pageSnapshotValidator } from "./lib/publicValidators"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; + +export const getSnapshot = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + shareToken: v.optional(v.string()), + }, + returns: pageSnapshotValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyReadable(ctx, strategy, args.shareToken); + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + + const [pageContents, elements, lineups] = await Promise.all([ + ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .take(2), + ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(), + ]); + if (pageContents.length !== 1) { + throw internalError("Each page must have exactly one page content row."); + } + + const referencedAssetIds = collectReferencedAssetIds(elements, lineups); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), + ), + ); + + return { + page: serializePageDescriptor(strategy.publicId, page), + content: serializePageContent(pageContents[0]!), + elements: elements + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((element) => + serializeElement(strategy.publicId, page.publicId, element), + ), + lineups: lineups + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((lineup) => + serializeLineup(strategy.publicId, page.publicId, lineup), + ), + assets: ( + await Promise.all( + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), + ) + ).sort((left, right) => left.publicId.localeCompare(right.publicId)), + }; + }, +}); diff --git a/convex/pageTrash.test.ts b/convex/pageTrash.test.ts new file mode 100644 index 00000000..7b8012fd --- /dev/null +++ b/convex/pageTrash.test.ts @@ -0,0 +1,1207 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { DataModel, Id } from "./_generated/dataModel"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import { PAGE_TRASH_RETENTION_MS } from "./lib/entities"; +import { UNKNOWN_DISPLAY_NAME } from "./lib/profile"; +import schema from "./schema"; +import { lineupsPayload, oneLineupPayload } from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const duplicateStrategy = makeFunctionReference<"mutation">( + "strategies:duplicate", +); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); +const listStrategies = makeFunctionReference<"query">( + "strategies:listForFolder", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const getShell = makeFunctionReference<"query">("strategy:getShell"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const getPageSnapshot = makeFunctionReference<"query">("page:getSnapshot"); +const listPages = makeFunctionReference<"query">("pages:listForStrategy"); +const addPageLegacy = makeFunctionReference<"mutation">("pages:add"); +const deletePageLegacy = makeFunctionReference<"mutation">("pages:delete"); +const renamePageLegacy = makeFunctionReference<"mutation">("pages:rename"); +const reorderPagesLegacy = makeFunctionReference<"mutation">("pages:reorder"); +const restorePage = makeFunctionReference<"mutation">("pages:restore"); +const listTrashed = makeFunctionReference<"query">("pages:listTrashed"); +const listElementsForPage = makeFunctionReference<"query">( + "elements:listForPage", +); +const listElementsForStrategy = makeFunctionReference<"query">( + "elements:listForStrategy", +); +const listLineupsForPage = makeFunctionReference<"query">( + "lineups:listForPage", +); +const listLineupsForStrategy = makeFunctionReference<"query">( + "lineups:listForStrategy", +); +const purgeTrashedPages = makeFunctionReference<"mutation">( + "maintenance:purgeTrashedPages", +); +const purgeDeletedPageOrphans = makeFunctionReference<"mutation">( + "maintenance:purgeDeletedPageOrphans", +); +const listReferencedAssetIds = makeFunctionReference<"query">( + "images:listReferencedAssetIds", +); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; +type OpResult = Record; + +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; +const strategyPublicId = "trash-strategy"; +const pageA = "trash-page-a"; +const pageB = "trash-page-b"; +const pageC = "trash-page-c"; +const day = 24 * 60 * 60 * 1000; + +function identity(subject: string) { + return { + issuer: "https://page-trash.test", + subject, + tokenIdentifier: `page-trash|${subject}`, + name: subject, + }; +} + +function agentPayload(type: string) { + return { + kind: "agent" as const, + payloadVersion: 1, + data: { type, elementType: "agent" }, + }; +} + +function textPayload(text: string) { + return { + kind: "text" as const, + payloadVersion: 1, + data: { text, elementType: "text" }, + }; +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +/// Two lineup groups: one of two lineups thrown from one sova origin, the +/// first showing an image, and one of a single lineup from another. +const lineupRows = [ + { + lineupPublicId: "k", + payload: lineupsPayload("k", { + origins: [{ id: "o", agentType: "sova" }], + landings: [{ id: "l" }, { id: "l2" }], + links: [ + { id: "k", originId: "o", landingId: "l", images: [{ id: "k-shot" }] }, + { id: "k-2", originId: "o", landingId: "l2" }, + ], + }), + }, + { + lineupPublicId: "k2", + payload: oneLineupPayload("k2", { + originId: "o2", + landingId: "l3", + agentType: "sova", + }), + }, +]; + +/** A ConvexError's code (convex-test passes its data as JSON text). */ +function errorCode(error: unknown): unknown { + const data = (error as { data?: unknown }).data; + return (typeof data === "string" ? JSON.parse(data) : data)?.code; +} + +async function expectCode(promise: Promise, code: string) { + const error = await promise.then( + () => null, + (caught: unknown) => caught, + ); + expect(error).not.toBeNull(); + expect(errorCode(error)).toBe(code); +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + await t.run(markAssetReferencesReady); + const owner = t.withIdentity(identity("owner")); + await owner.mutation(ensureCurrentUser, protocol); + return { t, owner }; +} + +async function member( + t: RootHarness, + owner: Harness, + subject: string, + role: "viewer" | "editor", +): Promise { + const user = t.withIdentity(identity(subject)); + await user.mutation(ensureCurrentUser, protocol); + const token = `${subject}-${role}-token`; + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: strategyPublicId, + token, + role, + }); + await user.mutation(redeemShare, { ...protocol, token }); + return user; +} + +let opCounter = 0; + +/// Sends [ops] as a client that restores deleted pages does, or, with +/// [oldClient], as one from before the trash. +async function apply( + user: Harness, + ops: Array>, + { oldClient = false }: { oldClient?: boolean } = {}, +): Promise { + const result = (await user.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "client", + ops: ops.map((op) => ({ opId: `op-${++opCounter}`, ...op })), + ...(oldClient ? {} : { checkTrashedPageDeletes: true }), + })) as { results: OpResult[] }; + return result.results; +} + +async function strategyRevision(user: Harness): Promise { + const shell = (await user.query(getShell, { + ...protocol, + strategyPublicId, + })) as { + header: { revision: number }; + }; + return shell.header.revision; +} + +async function livePageIds(user: Harness): Promise { + const shell = (await user.query(getShell, { + ...protocol, + strategyPublicId, + })) as { + pages: Array<{ publicId: string }>; + }; + return shell.pages.map((page) => page.publicId); +} + +/// A strategy of three pages. B, the one the tests delete, holds a text, an +/// agent, an image and a whole lineup; A holds an agent. +async function seed(owner: Harness) { + await owner.mutation(createStrategy, { + ...protocol, + publicId: strategyPublicId, + name: "Trash", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "Page 1", + initialPageIsAutoNamed: true, + initialPageIsAttack: true, + }); + let revision = await strategyRevision(owner); + for (const [pagePublicId, name, isAutoNamed, isAttack, sortIndex] of [ + [pageB, "Page 2", true, false, 1], + [pageC, "Retake", false, true, 2], + ] as const) { + await apply(owner, [ + { + type: "page.add", + pagePublicId, + payload: { name, isAutoNamed, isAttack }, + sortIndex, + expectedStrategyRevision: revision, + }, + ]); + revision += 1; + } + const results = await apply(owner, [ + { + type: "element.add", + elementPublicId: "a-agent", + pagePublicId: pageA, + payload: agentPayload("sova"), + sortIndex: 0, + }, + { + type: "element.add", + elementPublicId: "b-text", + pagePublicId: pageB, + payload: textPayload("hold B"), + sortIndex: 0, + }, + { + type: "element.add", + elementPublicId: "b-agent", + pagePublicId: pageB, + payload: agentPayload("jett"), + sortIndex: 1, + }, + { + type: "element.add", + elementPublicId: "b-image", + pagePublicId: pageB, + payload: imagePayload("b-image"), + sortIndex: 2, + }, + ...lineupRows.map((row, index) => ({ + type: "lineup.add", + lineupPublicId: row.lineupPublicId, + pagePublicId: pageB, + payload: row.payload, + sortIndex: index, + })), + ]); + expect(results.map((result) => result.status)).toEqual( + results.map(() => "applied"), + ); +} + +async function deleteB(owner: Harness) { + const [result] = await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageB, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]); + expect(result).toMatchObject({ status: "applied" }); +} + +async function pageRow(t: RootHarness, publicId: string) { + return await t.run(async (ctx) => + ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .first(), + ); +} + +async function rowsOnPage(t: RootHarness, pageId: Id<"pages">) { + return await t.run(async (ctx) => ({ + elements: await ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .collect(), + lineups: await ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .collect(), + contents: await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .collect(), + references: (await ctx.db.query("assetReferences").collect()).filter( + (reference) => reference.pageId === pageId, + ), + lineupAgents: (await ctx.db.query("lineupAgents").collect()).filter( + (row) => row.pageId === pageId, + ), + })); +} + +async function agentSummary(t: RootHarness): Promise { + return await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + const summary = await ctx.db + .query("strategyAgentSummaries") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy!._id)) + .unique(); + return summary?.agentTypes ?? []; + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("page trash", () => { + test("deleting a page moves it to the trash: its rows stay, every read hides it", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + const library = async () => + ( + (await owner.query(listStrategies, {})) as Array<{ + publicId: string; + attackLabel: string; + }> + ).find((entry) => entry.publicId === strategyPublicId)?.attackLabel; + expect(await library()).toBe("Mixed"); + expect(await agentSummary(t)).toEqual(["sova", "jett"]); + + await deleteB(owner); + + const trashed = await pageRow(t, pageB); + expect(trashed?.deletedAt).toEqual(expect.any(Number)); + const kept = await rowsOnPage(t, trashed!._id); + expect(kept.elements).toHaveLength(3); + expect(kept.lineups).toHaveLength(2); + expect(kept.contents).toHaveLength(1); + expect(kept.references.map((reference) => reference.assetPublicId).sort()) + .toEqual(["b-image", "k-shot"]); + // The lineups' agent rows stay with them for a restore; the summary + // below leaves them out because their page is in the trash. + expect(kept.lineupAgents.map((row) => row.lineupId).sort()).toEqual( + kept.lineups.map((row) => row._id).sort(), + ); + + const shell = (await owner.query(getShell, { + ...protocol, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; name: string }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageA, sortIndex: 0, name: "Page 1" }, + { publicId: pageC, sortIndex: 1, name: "Retake" }, + ]); + expect( + ((await owner.query(listPages, { strategyPublicId })) as Array<{ + publicId: string; + }>).map((page) => page.publicId), + ).toEqual([pageA, pageC]); + const full = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId, + acceptsTrashedPagesLeftOut: true, + })) as { + pages: Array<{ publicId: string }>; + elements: Array<{ publicId: string }>; + lineups: unknown[]; + assets: unknown[]; + }; + expect(full.pages.map((page) => page.publicId)).toEqual([pageA, pageC]); + expect(full.elements.map((element) => element.publicId)).toEqual([ + "a-agent", + ]); + expect(full.lineups).toEqual([]); + for (const read of [getPageSnapshot, listElementsForPage, listLineupsForPage]) { + await expectCode( + owner.query(read, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }), + "NOT_FOUND", + ); + } + expect( + ((await owner.query(listElementsForStrategy, { + ...protocol, + strategyPublicId, + })) as Array<{ publicId: string }>).map((element) => element.publicId), + ).toEqual(["a-agent"]); + expect( + await owner.query(listLineupsForStrategy, { + ...protocol, + strategyPublicId, + }), + ).toEqual([]); + // The library and folder tree read only live pages. + expect(await library()).toBe("Attack"); + expect(await agentSummary(t)).toEqual(["sova"]); + }); + + test("a full snapshot is refused to a client that would read the trash's pages as gone, while the strategy has some", async () => { + const { owner } = await createHarness(); + await seed(owner); + // Before anything is trashed, every client reads it as always. + await expect( + owner.query(getFullSnapshot, { ...protocol, strategyPublicId }), + ).resolves.toMatchObject({ pages: expect.any(Array) }); + + await deleteB(owner); + + // An older client decides from the snapshot whether an upload is still + // wanted: without B it would drop B's image. It is told to upgrade. + await expectCode( + owner.query(getFullSnapshot, { ...protocol, strategyPublicId }), + "CLIENT_UPGRADE_REQUIRED", + ); + await expect( + owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId, + acceptsTrashedPagesLeftOut: true, + }), + ).resolves.toMatchObject({ pages: expect.any(Array) }); + + // Restored, the strategy has no trash left, and older clients read it. + await owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }); + await expect( + owner.query(getFullSnapshot, { ...protocol, strategyPublicId }), + ).resolves.toMatchObject({ pages: expect.any(Array) }); + }); + + test("an old client's pages:delete trashes the page the same way", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + + await owner.mutation(deletePageLegacy, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + expectedRevision: await strategyRevision(owner), + }); + + const trashed = await pageRow(t, pageB); + expect(trashed?.deletedAt).toEqual(expect.any(Number)); + expect((await rowsOnPage(t, trashed!._id)).elements).toHaveLength(3); + expect(await livePageIds(owner)).toEqual([pageA, pageC]); + expect(await agentSummary(t)).toEqual(["sova"]); + // Deleting it again changes nothing. + await expect( + owner.mutation(deletePageLegacy, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + expectedRevision: await strategyRevision(owner), + }), + ).resolves.toMatchObject({ ok: true, reused: true }); + }); + + test("a page in the trash refuses every change with PAGE_DELETED and keeps its rows as they were", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + const trashed = await pageRow(t, pageB); + const before = await rowsOnPage(t, trashed!._id); + const revision = await strategyRevision(owner); + + const results = await apply(owner, [ + { + type: "element.add", + elementPublicId: "b-new", + pagePublicId: pageB, + payload: textPayload("new"), + sortIndex: 3, + }, + { + type: "element.patch", + elementPublicId: "b-text", + pagePublicId: pageB, + payload: textPayload("edited"), + expectedElementRevision: 1, + }, + { + type: "element.reorder", + elementPublicId: "b-text", + pagePublicId: pageB, + sortIndex: 9, + expectedElementRevision: 1, + }, + { + type: "element.delete", + elementPublicId: "b-agent", + pagePublicId: pageB, + expectedElementRevision: 1, + }, + // Moving content off the trashed page, or onto it. + { + type: "element.patch", + elementPublicId: "b-image", + pagePublicId: pageA, + expectedElementRevision: 1, + }, + { + type: "element.patch", + elementPublicId: "a-agent", + pagePublicId: pageB, + expectedElementRevision: 1, + }, + { + type: "lineup.add", + lineupPublicId: "k3", + pagePublicId: pageB, + payload: oneLineupPayload("k3", { originId: "o3", landingId: "l4" }), + sortIndex: 3, + }, + { + type: "lineup.patch", + lineupPublicId: "k", + pagePublicId: pageB, + payload: { + ...lineupRows[0]!.payload, + data: { + ...lineupRows[0]!.payload.data, + links: lineupRows[0]!.payload.data.links.map((link) => ({ + ...link, + name: "edited", + })), + }, + }, + expectedLineupRevision: 1, + }, + { + type: "lineup.delete", + lineupPublicId: "k", + pagePublicId: pageB, + expectedLineupRevision: 1, + }, + { + type: "pageContent.patch", + pagePublicId: pageB, + settings: { agentSize: 40, abilitySize: 30, useNeutralTeamColors: true }, + expectedPageContentRevision: 1, + }, + { + type: "page.patch", + pagePublicId: pageB, + payload: { name: "Renamed" }, + expectedPageRevision: trashed!.revision, + }, + { + type: "page.reorder", + pagePublicId: pageB, + sortIndex: 0, + expectedStrategyRevision: revision, + }, + { + type: "page.add", + pagePublicId: pageB, + payload: { name: "Page 2", isAutoNamed: true, isAttack: false }, + sortIndex: 1, + expectedStrategyRevision: revision, + }, + ]); + for (const result of results) { + expect(result).toMatchObject({ + status: "failed", + code: "PAGE_DELETED", + message: "This page was deleted", + }); + } + + // Deleting it again is already done. + expect( + await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageB, + expectedStrategyRevision: revision, + }, + ]), + ).toMatchObject([{ status: "noop" }]); + await expectCode( + owner.mutation(renamePageLegacy, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + name: "Renamed", + expectedRevision: trashed!.revision, + }), + "NOT_FOUND", + ); + await expectCode( + owner.mutation(addPageLegacy, { + ...protocol, + strategyPublicId, + expectedRevision: revision, + pagePublicId: pageB, + name: "Page 2", + isAutoNamed: true, + sortIndex: 1, + isAttack: false, + }), + "PAGE_DELETED", + ); + + const after = await rowsOnPage(t, trashed!._id); + expect(after).toEqual(before); + expect(await pageRow(t, pageB)).toEqual(trashed); + expect(await strategyRevision(owner)).toBe(revision); + }); + + test("restoring brings the page back at its place with everything on it; a refused change lands when sent again", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + const snapshotBefore = await owner.query(getPageSnapshot, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }); + await deleteB(owner); + const edit = { + type: "element.patch", + elementPublicId: "b-text", + pagePublicId: pageB, + payload: textPayload("edited while trashed"), + expectedElementRevision: 1, + }; + const [refused] = await apply(owner, [{ opId: "edit-1", ...edit }]); + expect(refused).toMatchObject({ status: "failed", code: "PAGE_DELETED" }); + const revision = await strategyRevision(owner); + + await expect( + owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }), + ).resolves.toEqual({ ok: true, revision: revision + 1 }); + + expect(await livePageIds(owner)).toEqual([pageA, pageB, pageC]); + expect( + await owner.query(getPageSnapshot, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }), + ).toEqual(snapshotBefore); + expect((await pageRow(t, pageB))?.deletedAt).toBeUndefined(); + expect(await agentSummary(t)).toEqual(["sova", "jett"]); + // The same op id replays what the server answered it; the change sent + // again under a new id lands on the restored page. + expect(await apply(owner, [{ opId: "edit-1", ...edit }])).toMatchObject([ + { status: "failed", code: "PAGE_DELETED" }, + ]); + expect(await apply(owner, [{ opId: "edit-2", ...edit }])).toMatchObject([ + { status: "applied", appliedRevision: 2 }, + ]); + + // Restoring a live page changes nothing. + await expect( + owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }), + ).resolves.toEqual({ ok: true, reused: true, revision: revision + 1 }); + }); + + test("a restored page goes last when fewer pages are left than its old place, and auto-names follow the order", async () => { + const { owner } = await createHarness(); + await seed(owner); + await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageC, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]); + await deleteB(owner); + expect(await livePageIds(owner)).toEqual([pageA]); + + await owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageC, + }); + await owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }); + + const shell = (await owner.query(getShell, { + ...protocol, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; name: string }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageA, sortIndex: 0, name: "Page 1" }, + { publicId: pageB, sortIndex: 1, name: "Page 2" }, + { publicId: pageC, sortIndex: 2, name: "Retake" }, + ]); + }); + + test("anyone who can delete a page can restore it, and no one else", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + const viewer = await member(t, owner, "viewer", "viewer"); + const editor = await member(t, owner, "editor", "editor"); + const stranger = t.withIdentity(identity("stranger")); + await stranger.mutation(ensureCurrentUser, protocol); + await deleteB(editor); + const args = { ...protocol, strategyPublicId, pagePublicId: pageB }; + + await expectCode(viewer.mutation(restorePage, args), "FORBIDDEN"); + await expectCode(stranger.mutation(restorePage, args), "FORBIDDEN"); + await expect(editor.mutation(restorePage, args)).resolves.toMatchObject({ + ok: true, + }); + expect(await livePageIds(viewer)).toEqual([pageA, pageB, pageC]); + }); + + test("a page never in this strategy cannot be restored", async () => { + const { owner } = await createHarness(); + await seed(owner); + await expectCode( + owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: "never-existed", + }), + "NOT_FOUND", + ); + }); + + test("an old client's delete of content on a trashed page gets the no-op it got when the content was purged", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + const trashed = await pageRow(t, pageB); + const before = await rowsOnPage(t, trashed!._id); + + const results = await apply( + owner, + [ + { + type: "element.delete", + elementPublicId: "b-agent", + pagePublicId: pageB, + expectedElementRevision: 1, + }, + { + type: "lineup.delete", + lineupPublicId: "k", + pagePublicId: pageB, + expectedLineupRevision: 1, + }, + // Anything that adds or changes content is still refused. + { + type: "element.patch", + elementPublicId: "b-text", + pagePublicId: pageB, + payload: textPayload("edited"), + expectedElementRevision: 1, + }, + ], + { oldClient: true }, + ); + + expect(results).toMatchObject([ + { status: "noop" }, + { status: "noop" }, + { status: "failed", code: "PAGE_DELETED" }, + ]); + // Nothing on the page changed: restoring it brings it back whole. + expect(await rowsOnPage(t, trashed!._id)).toEqual(before); + }); + + test("a replayed rejection shows no current copy of content in the trash", async () => { + const { owner } = await createHarness(); + await seed(owner); + const stale = { + opId: "stale-edit", + type: "element.patch", + elementPublicId: "b-text", + pagePublicId: pageB, + payload: textPayload("stale"), + expectedElementRevision: 7, + }; + expect(await apply(owner, [stale])).toMatchObject([ + { status: "rejected", current: { revision: 1 } }, + ]); + await deleteB(owner); + + // The answer was lost; the same op is sent again. + const [replayed] = await apply(owner, [stale]); + expect(replayed).toMatchObject({ status: "rejected" }); + expect(replayed).not.toHaveProperty("current"); + }); + + test("a trashed page is kept for the whole retention, then cannot be restored and is purged with everything on it", async () => { + vi.useFakeTimers(); + const { t, owner } = await createHarness(); + await seed(owner); + // More content than one purge run takes, so the purge reschedules. + await apply( + owner, + Array.from({ length: 20 }, (_, index) => ({ + type: "element.add", + elementPublicId: `b-extra-${index}`, + pagePublicId: pageB, + payload: textPayload(`extra ${index}`), + sortIndex: 10 + index, + })), + ); + await deleteB(owner); + const trashed = await pageRow(t, pageB); + const liveA = await pageRow(t, pageA); + const aRows = await rowsOnPage(t, liveA!._id); + // A client holding an upload for one of the page's images keeps it + // while the page can be restored. + expect( + await owner.query(listReferencedAssetIds, { + strategyPublicId, + assetPublicIds: ["b-image", "k-shot"], + }), + ).toEqual(["b-image", "k-shot"]); + + vi.setSystemTime(Date.now() + PAGE_TRASH_RETENTION_MS - day); + await t.mutation(purgeTrashedPages, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await pageRow(t, pageB)).toEqual(trashed); + expect((await rowsOnPage(t, trashed!._id)).elements).toHaveLength(23); + + vi.setSystemTime(Date.now() + 2 * day); + await expectCode( + owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }), + "NOT_FOUND", + ); + await t.mutation(purgeTrashedPages, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await pageRow(t, pageB)).toBeNull(); + expect(await rowsOnPage(t, trashed!._id)).toEqual({ + elements: [], + lineups: [], + contents: [], + references: [], + lineupAgents: [], + }); + expect(await rowsOnPage(t, liveA!._id)).toEqual(aRows); + expect(await livePageIds(owner)).toEqual([pageA, pageC]); + expect( + await owner.query(listReferencedAssetIds, { + strategyPublicId, + assetPublicIds: ["b-image", "k-shot"], + }), + ).toEqual([]); + }); + + test("the orphan purge scheduled by older deletes leaves a page that still has its row alone", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + const trashed = await pageRow(t, pageB); + const before = await rowsOnPage(t, trashed!._id); + + await t.mutation(purgeDeletedPageOrphans, { + pageId: trashed!._id, + strategyId: trashed!.strategyId, + }); + + expect(await rowsOnPage(t, trashed!._id)).toEqual(before); + }); + + test("the last live page cannot be deleted, whatever is in the trash", async () => { + const { owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageC, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]); + + expect( + await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageA, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]), + ).toMatchObject([{ status: "failed", code: "INVALID_OP" }]); + await expect( + owner.mutation(deletePageLegacy, { + ...protocol, + strategyPublicId, + pagePublicId: pageA, + expectedRevision: await strategyRevision(owner), + }), + ).rejects.toThrow("Cannot delete last page"); + }); + + test("old clients' reorder and add see only live pages", async () => { + const { owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + + // An old client lists the live pages it was shown. + await owner.mutation(reorderPagesLegacy, { + ...protocol, + strategyPublicId, + orderedPagePublicIds: [pageC, pageA], + expectedRevision: await strategyRevision(owner), + }); + expect(await livePageIds(owner)).toEqual([pageC, pageA]); + + await owner.mutation(addPageLegacy, { + ...protocol, + strategyPublicId, + expectedRevision: await strategyRevision(owner), + pagePublicId: "legacy-added", + name: "Legacy", + sortIndex: 9, + isAttack: true, + }); + await apply(owner, [ + { + type: "page.add", + pagePublicId: "op-added", + payload: { name: "Op", isAttack: true }, + sortIndex: 9, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]); + const shell = (await owner.query(getShell, { + ...protocol, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageC, sortIndex: 0 }, + { publicId: pageA, sortIndex: 1 }, + { publicId: "legacy-added", sortIndex: 2 }, + { publicId: "op-added", sortIndex: 3 }, + ]); + + // Restored into that order, B takes its old place, second. + await owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }); + expect(await livePageIds(owner)).toEqual([ + pageC, + pageB, + pageA, + "legacy-added", + "op-added", + ]); + }); + + test("duplicating a strategy leaves its trashed pages behind", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + + await owner.mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: strategyPublicId, + publicId: "trash-copy", + name: "Trash (Copy)", + }); + + const copy = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId: "trash-copy", + })) as { + pages: Array<{ name: string }>; + elements: Array<{ payload: { kind: string } }>; + lineups: unknown[]; + }; + expect(copy.pages.map((page) => page.name)).toEqual(["Page 1", "Retake"]); + expect(copy.elements.map((element) => element.payload.kind)).toEqual([ + "agent", + ]); + expect(copy.lineups).toEqual([]); + const copyPageCount = await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "trash-copy")) + .first(); + return ( + await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy!._id)) + .collect() + ).length; + }); + expect(copyPageCount).toBe(2); + }); + + test("deleting the strategy leaves its trashed pages to the trash purge", async () => { + vi.useFakeTimers(); + const { t, owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + const trashed = await pageRow(t, pageB); + + await owner.mutation(deleteStrategy, { + ...protocol, + strategyPublicId, + expectedRevision: await strategyRevision(owner), + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + // Live pages go now, one scheduled purge each; the trash waits. + expect(await pageRow(t, pageA)).toBeNull(); + expect(await pageRow(t, pageB)).not.toBeNull(); + + vi.setSystemTime(Date.now() + PAGE_TRASH_RETENTION_MS + 1); + await t.mutation(purgeTrashedPages, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await pageRow(t, pageB)).toBeNull(); + expect(await rowsOnPage(t, trashed!._id)).toEqual({ + elements: [], + lineups: [], + contents: [], + references: [], + lineupAgents: [], + }); + }); +}); + +describe("Recently deleted", () => { + test("lists the strategy's restorable pages, newest first, with who deleted each", async () => { + vi.useFakeTimers(); + const { t, owner } = await createHarness(); + await seed(owner); + const editor = await member(t, owner, "editor", "editor"); + await deleteB(owner); + const deletedB = Date.now(); + vi.setSystemTime(deletedB + day); + // An old client's delete records its deleter too. + await editor.mutation(deletePageLegacy, { + ...protocol, + strategyPublicId, + pagePublicId: pageC, + expectedRevision: await strategyRevision(editor), + }); + + const trashed = await owner.query(listTrashed, { strategyPublicId }); + expect(trashed).toEqual([ + { + publicId: pageC, + name: "Retake", + deletedAt: deletedB + day, + restorableUntil: deletedB + day + PAGE_TRASH_RETENTION_MS, + deletedByName: "editor", + deletedByYou: false, + }, + { + publicId: pageB, + name: "Page 2", + deletedAt: deletedB, + restorableUntil: deletedB + PAGE_TRASH_RETENTION_MS, + deletedByName: "owner", + deletedByYou: true, + }, + ]); + expect((await pageRow(t, pageB))?.deletedBy).toEqual(expect.any(String)); + + // Restored, a page leaves the list and forgets who deleted it. + await owner.mutation(restorePage, { + ...protocol, + strategyPublicId, + pagePublicId: pageB, + }); + expect( + (await owner.query(listTrashed, { strategyPublicId })).map( + (page: { publicId: string }) => page.publicId, + ), + ).toEqual([pageC]); + expect((await pageRow(t, pageB))?.deletedBy).toBeUndefined(); + }); + + test("a deleter who is not known shows no name", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + const editor = await member(t, owner, "editor", "editor"); + await deleteB(editor); + await apply(owner, [ + { + type: "page.delete", + pagePublicId: pageC, + expectedStrategyRevision: await strategyRevision(owner), + }, + ]); + // B as trashed before deleters were recorded; C's deleter has no name. + const b = await pageRow(t, pageB); + await t.run(async (ctx) => { + await ctx.db.patch(b!._id, { deletedBy: undefined }); + const owners = await ctx.db.query("users").collect(); + for (const user of owners) { + if (user.displayName === "owner") { + await ctx.db.patch(user._id, { displayName: UNKNOWN_DISPLAY_NAME }); + } + } + }); + + expect( + (await editor.query(listTrashed, { strategyPublicId })).map( + (page: { publicId: string; deletedByName: string | null }) => [ + page.publicId, + page.deletedByName, + ], + ), + ).toEqual( + expect.arrayContaining([ + [pageB, null], + [pageC, null], + ]), + ); + }); + + test("a page past its time in the trash is not listed", async () => { + vi.useFakeTimers(); + const { owner } = await createHarness(); + await seed(owner); + await deleteB(owner); + + vi.setSystemTime(Date.now() + PAGE_TRASH_RETENTION_MS - 1); + expect(await owner.query(listTrashed, { strategyPublicId })).toHaveLength( + 1, + ); + vi.setSystemTime(Date.now() + 2); + expect(await owner.query(listTrashed, { strategyPublicId })).toEqual([]); + }); + + test("only those who can restore a page see the list", async () => { + const { t, owner } = await createHarness(); + await seed(owner); + const viewer = await member(t, owner, "viewer", "viewer"); + const stranger = t.withIdentity(identity("stranger")); + await stranger.mutation(ensureCurrentUser, protocol); + await deleteB(owner); + + await expectCode( + viewer.query(listTrashed, { strategyPublicId }), + "FORBIDDEN", + ); + await expectCode( + stranger.query(listTrashed, { strategyPublicId }), + "FORBIDDEN", + ); + }); +}); diff --git a/convex/pages.ts b/convex/pages.ts new file mode 100644 index 00000000..7476a57e --- /dev/null +++ b/convex/pages.ts @@ -0,0 +1,373 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { assertStrategyRole } from "./lib/auth"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + clampPageIndex, + getPageByPublicId, + getStrategyByPublicId, + isPastTrashRetention, + isTrashed, + listLivePages, + listRestorablePages, + PAGE_TRASH_RETENTION_MS, + restoreTrashedPage, + sortByNumberField, + trashPage, +} from "./lib/entities"; +import { strategySettingsValidator } from "./lib/payloadValidators"; +import { + conflictError, + invalidOpError, + notFoundError, + errorWithCode, + internalError, + pageDeletedError, +} from "./lib/errors"; +import { serializePageDescriptor } from "./lib/snapshotSerialization"; +import { valuesEqual } from "./lib/canonicalValues"; +import { refreshStrategyAgentSummary } from "./lib/strategyAgentSummary"; +import { + pageDescriptorValidator, + revisionResultValidator, + trashedPageValidator, +} from "./lib/publicValidators"; +import { UNKNOWN_DISPLAY_NAME } from "./lib/profile"; + +export const listForStrategy = query({ + args: { strategyPublicId: v.string() }, + returns: v.array(pageDescriptorValidator), + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "viewer"); + const pages = await listLivePages(ctx, strategy._id); + return sortByNumberField(pages, "sortIndex").map((page) => + serializePageDescriptor(strategy.publicId, page), + ); + }, +}); + +export const add = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + expectedRevision: v.number(), + pagePublicId: v.string(), + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + sortIndex: v.number(), + isAttack: v.boolean(), + settings: v.optional(strategySettingsValidator), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + const existingPage = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", args.pagePublicId)) + .first(); + const pages = await listLivePages(ctx, strategy._id); + + if (existingPage !== null) { + if (existingPage.strategyId !== strategy._id) { + throw conflictError( + `Page publicId already exists: ${args.pagePublicId}`, + ); + } + if (isTrashed(existingPage)) throw pageDeletedError(); + const pageContents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", existingPage._id)) + .take(2); + if (pageContents.length !== 1) { + throw internalError( + "Each page must have exactly one page content row.", + ); + } + const desiredSortIndex = clampPageIndex( + args.sortIndex, + Math.max(0, pages.length - 1), + ); + const identical = + existingPage.name === args.name && + existingPage.isAutoNamed === args.isAutoNamed && + existingPage.sortIndex === desiredSortIndex && + existingPage.isAttack === args.isAttack && + valuesEqual(pageContents[0]!.settings, args.settings); + if (identical) { + return { ok: true, reused: true, revision: strategy.revision } as const; + } + throw conflictError(`Page publicId already exists: ${args.pagePublicId}`); + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + const now = Date.now(); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const desiredSortIndex = clampPageIndex(args.sortIndex, orderedPages.length); + for (let index = 0; index < orderedPages.length; index += 1) { + const page = orderedPages[index]!; + const normalizedIndex = index >= desiredSortIndex ? index + 1 : index; + const normalizedName = + page.isAutoNamed === true + ? `Page ${normalizedIndex + 1}` + : page.name; + if ( + page.sortIndex !== normalizedIndex || + page.name !== normalizedName + ) { + await ctx.db.patch(page._id, { + name: normalizedName, + sortIndex: normalizedIndex, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const pageId = await ctx.db.insert("pages", { + publicId: args.pagePublicId, + strategyId: strategy._id, + name: args.name, + ...(args.isAutoNamed === undefined + ? {} + : { isAutoNamed: args.isAutoNamed }), + sortIndex: desiredSortIndex, + isAttack: args.isAttack, + revision: 1, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("pageContents", { + pageId, + settings: args.settings, + revision: 1, + createdAt: now, + updatedAt: now, + }); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision } as const; + }, +}); + +export const rename = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + expectedRevision: v.number(), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + const page = await getPageByPublicId(ctx, args.pagePublicId); + if (page.strategyId !== strategy._id) { + throw errorWithCode("PAGE_STRATEGY_MISMATCH", "Page strategy mismatch"); + } + const nextIsAutoNamed = args.isAutoNamed ?? page.isAutoNamed; + if ( + page.name === args.name && + page.isAutoNamed === nextIsAutoNamed + ) { + return { ok: true, reused: true, revision: page.revision } as const; + } + if (args.expectedRevision !== page.revision) { + throw conflictError("Page revision mismatch"); + } + + const revision = page.revision + 1; + await ctx.db.patch(page._id, { + name: args.name, + isAutoNamed: nextIsAutoNamed, + revision, + updatedAt: Date.now(), + }); + return { ok: true, revision } as const; + }, +}); + +/// Moves a page to the trash (see trashPage). Deleting a page already in +/// the trash, or gone, changes nothing. +const deletePage = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + expectedRevision: v.number(), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user } = await assertStrategyRole(ctx, strategy, "editor"); + const pages = await listLivePages(ctx, strategy._id); + const page = pages.find( + (candidate) => candidate.publicId === args.pagePublicId, + ); + if (page === undefined) { + return { ok: true, reused: true, revision: strategy.revision } as const; + } + if (pages.length <= 1) { + throw invalidOpError("Cannot delete last page"); + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + const now = Date.now(); + await trashPage(ctx, page, pages, user._id, now); + await refreshStrategyAgentSummary(ctx, strategy._id); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision } as const; + }, +}); + +/// Brings a page back from the trash with everything on it, at the place it +/// was deleted from (see restoreTrashedPage). Anyone who can delete a page +/// can restore it. A page past its time in the trash, purged, or never in +/// this strategy is not found; restoring a live page changes nothing. +export const restore = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + pagePublicId: v.string(), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + const page = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", args.pagePublicId)) + .first(); + const now = Date.now(); + if ( + page === null || + page.strategyId !== strategy._id || + isPastTrashRetention(page, now) + ) { + throw notFoundError("Page", args.pagePublicId); + } + if (!isTrashed(page)) { + return { ok: true, reused: true, revision: strategy.revision } as const; + } + + await restoreTrashedPage( + ctx, + page, + await listLivePages(ctx, strategy._id), + now, + ); + await refreshStrategyAgentSummary(ctx, strategy._id); + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision } as const; + }, +}); + +/// The strategy's pages in the trash that can still be restored, most +/// recently deleted first, each with who deleted it, if known. Only those +/// who can delete and restore a page see them. +export const listTrashed = query({ + args: { strategyPublicId: v.string() }, + returns: v.array(trashedPageValidator), + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user } = await assertStrategyRole(ctx, strategy, "editor"); + const pages = await listRestorablePages(ctx, strategy._id, Date.now()); + // One read per deleter, however many pages each deleted. + const deleters = new Map, Promise | null>>(); + for (const page of pages) { + if (page.deletedBy !== undefined && !deleters.has(page.deletedBy)) { + deleters.set(page.deletedBy, ctx.db.get(page.deletedBy)); + } + } + return await Promise.all( + pages.map(async (page) => { + const deletedAt = page.deletedAt!; + const deleter = + page.deletedBy === undefined + ? null + : await deleters.get(page.deletedBy)!; + return { + publicId: page.publicId, + name: page.name, + deletedAt, + restorableUntil: deletedAt + PAGE_TRASH_RETENTION_MS, + deletedByName: + deleter === null || deleter.displayName === UNKNOWN_DISPLAY_NAME + ? null + : deleter.displayName, + deletedByYou: page.deletedBy === user._id, + }; + }), + ); + }, +}); + +export const reorder = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + orderedPagePublicIds: v.array(v.string()), + expectedRevision: v.number(), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + const pages = await listLivePages(ctx, strategy._id); + if (pages.length !== args.orderedPagePublicIds.length) { + throw invalidOpError("Page count mismatch"); + } + if (new Set(args.orderedPagePublicIds).size !== pages.length) { + throw invalidOpError("Page order must include each page exactly once"); + } + const pageByPublicId = new Map(pages.map((page) => [page.publicId, page])); + const ordered = args.orderedPagePublicIds.map((publicId) => { + const page = pageByPublicId.get(publicId); + if (page === undefined) throw notFoundError("Page", publicId); + return page; + }); + if (ordered.every((page, index) => page.sortIndex === index)) { + return { ok: true, reused: true, revision: strategy.revision } as const; + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + const now = Date.now(); + for (let index = 0; index < ordered.length; index += 1) { + const page = ordered[index]!; + const normalizedName = + page.isAutoNamed === true ? `Page ${index + 1}` : page.name; + if (page.sortIndex !== index || page.name !== normalizedName) { + await ctx.db.patch(page._id, { + name: normalizedName, + sortIndex: index, + revision: page.revision + 1, + updatedAt: now, + }); + } + } + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { revision, updatedAt: now }); + return { ok: true, revision } as const; + }, +}); + +export { deletePage as delete }; diff --git a/convex/presence.test.ts b/convex/presence.test.ts new file mode 100644 index 00000000..39ad8806 --- /dev/null +++ b/convex/presence.test.ts @@ -0,0 +1,196 @@ +import { convexTest } from "convex-test"; +import { makeFunctionReference, type UserIdentity } from "convex/server"; +import type { JSONValue } from "convex/values"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { verifyPass } from "../presence/src/pass"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); +const issueRoomPass = makeFunctionReference<"mutation">( + "presence:issueRoomPass", +); + +const SECRET = "presence-test-secret"; +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; + +function identity( + subject: string, + claims: Record = {}, +): Partial { + return { + issuer: "https://presence.test", + subject, + tokenIdentifier: `presence|${subject}`, + name: `User ${subject}`, + ...claims, + }; +} + +async function harness() { + const t = convexTest(schema, modules); + const owner = t.withIdentity( + // What Convex actually hands over for a Supabase Discord sign-in: nested + // claims flattened into dotted keys. + identity("owner", { + "user_metadata.full_name": "ana", + "user_metadata.avatar_url": "https://cdn.discordapp.com/avatars/1/a.png", + "user_metadata.custom_claims.global_name": "Ana", + }), + ); + const viewer = t.withIdentity(identity("viewer")); + const stranger = t.withIdentity(identity("stranger")); + for (const user of [owner, viewer, stranger]) { + await user.mutation(ensureCurrentUser, protocol); + } + await owner.mutation(createStrategy, { + ...protocol, + publicId: "strat-1", + name: "Split B exec", + mapData: "split", + initialPagePublicId: "page-1", + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: "strat-1", + token: "viewer-token", + role: "viewer", + }); + await viewer.mutation(redeemShare, { ...protocol, token: "viewer-token" }); + return { owner, viewer, stranger }; +} + +describe("presence:issueRoomPass", () => { + beforeEach(() => { + process.env.PRESENCE_URL = "https://presence.test/"; + process.env.PRESENCE_PASS_SECRET = SECRET; + }); + afterEach(() => { + delete process.env.PRESENCE_URL; + delete process.env.PRESENCE_PASS_SECRET; + }); + + test("signs a pass the Worker accepts, with the Discord profile", async () => { + const { owner } = await harness(); + const issued = await owner.mutation(issueRoomPass, { + ...protocol, + strategyPublicId: "strat-1", + }); + expect(issued.url).toBe("wss://presence.test/v1/rooms/strat-1"); + const claims = await verifyPass(issued.pass, SECRET, Date.now()); + expect(claims).toMatchObject({ + room: "strat-1", + name: "Ana", + avatar: "https://cdn.discordapp.com/avatars/1/a.png", + role: "owner", + exp: issued.expiresAt, + }); + expect(claims!.uid).toMatch(/^[0-9a-f]{24}$/); + }); + + test("gives a viewer a viewer pass and the same uid every time", async () => { + const { viewer } = await harness(); + const args = { ...protocol, strategyPublicId: "strat-1" }; + const first = await verifyPass( + (await viewer.mutation(issueRoomPass, args)).pass, + SECRET, + Date.now(), + ); + const second = await verifyPass( + (await viewer.mutation(issueRoomPass, args)).pass, + SECRET, + Date.now(), + ); + expect(first).toMatchObject({ role: "viewer", name: "User viewer" }); + expect(second!.uid).toBe(first!.uid); + }); + + test("reads the Discord profile however the claims are nested", async () => { + const t = convexTest(schema, modules); + const shapes: Record[] = [ + { "user_metadata.full_name": "bo_b", "user_metadata.avatar_url": "https://x/b.png" }, + { "user_metadata.custom_claims": { global_name: "Bo" }, "user_metadata.avatar_url": "https://x/b.png" }, + { user_metadata: { custom_claims: { global_name: "Bo" }, avatar_url: "https://x/b.png" } }, + ]; + const names: string[] = []; + for (const [index, claims] of shapes.entries()) { + const user = t.withIdentity(identity(`shape-${index}`, claims)); + await user.mutation(ensureCurrentUser, protocol); + await user.mutation(createStrategy, { + ...protocol, + publicId: `strat-shape-${index}`, + name: "Mine", + mapData: "bind", + initialPagePublicId: `page-shape-${index}`, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + const issued = await user.mutation(issueRoomPass, { + ...protocol, + strategyPublicId: `strat-shape-${index}`, + }); + const claimsOut = await verifyPass(issued.pass, SECRET, Date.now()); + expect(claimsOut!.avatar).toBe("https://x/b.png"); + names.push(claimsOut!.name); + } + expect(names).toEqual(["bo_b", "Bo", "Bo"]); + }); + + test("names an email sign-in by its address, never the placeholder", async () => { + const t = convexTest(schema, modules); + const emailUser = t.withIdentity({ + issuer: "https://presence.test", + subject: "email-user", + tokenIdentifier: "presence|email-user", + email: "ana.lyst@example.com", + }); + await emailUser.mutation(ensureCurrentUser, protocol); + await emailUser.mutation(createStrategy, { + ...protocol, + publicId: "strat-email", + name: "Mine", + mapData: "bind", + initialPagePublicId: "page-email", + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + const issued = await emailUser.mutation(issueRoomPass, { + ...protocol, + strategyPublicId: "strat-email", + }); + const claims = await verifyPass(issued.pass, SECRET, Date.now()); + expect(claims!.name).toBe("ana.lyst"); + }); + + test("refuses someone without access", async () => { + const { stranger } = await harness(); + await expect( + stranger.mutation(issueRoomPass, { + ...protocol, + strategyPublicId: "strat-1", + }), + ).rejects.toThrow("Forbidden"); + }); + + test("returns null when the deployment has no presence service", async () => { + const { owner } = await harness(); + delete process.env.PRESENCE_URL; + await expect( + owner.mutation(issueRoomPass, { + ...protocol, + strategyPublicId: "strat-1", + }), + ).resolves.toBeNull(); + }); +}); diff --git a/convex/presence.ts b/convex/presence.ts new file mode 100644 index 00000000..a2132448 --- /dev/null +++ b/convex/presence.ts @@ -0,0 +1,95 @@ +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; +import { signPass } from "../presence/src/pass"; +import { assertStrategyRole } from "./lib/auth"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { getStrategyByPublicId } from "./lib/entities"; +import { profileFromIdentity, UNKNOWN_DISPLAY_NAME } from "./lib/profile"; + +// Live presence runs outside Convex, in the icarus-presence Worker +// (presence/). Convex's only part is deciding who may join a strategy's room: +// it checks access here and signs a short-lived pass the Worker verifies with +// the secret both share. Nothing about presence is stored. + +/** Clients renew at half this, so a revoked user drops within one TTL. */ +export const ROOM_PASS_TTL_MS = 2 * 60 * 1000; +const MAX_NAME_CHARS = 64; +const MAX_AVATAR_URL_CHARS = 512; + +export const issueRoomPass = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + }, + // Null when this deployment has no presence service configured; the client + // then shows no presence rather than an error. + returns: v.union( + v.object({ + url: v.string(), + pass: v.string(), + expiresAt: v.number(), + }), + v.null(), + ), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { user, role } = await assertStrategyRole(ctx, strategy, "viewer"); + + const baseUrl = process.env.PRESENCE_URL; + const secret = process.env.PRESENCE_PASS_SECRET; + if (!baseUrl || !secret) return null; + + // Read the profile from this sign-in rather than the users row, so a + // name or avatar changed on Discord shows at the next renewal. + const profile = profileFromIdentity(await ctx.auth.getUserIdentity()); + const storedName = + user.displayName === UNKNOWN_DISPLAY_NAME ? null : user.displayName; + const name = truncate( + profile.name ?? storedName ?? "Teammate", + MAX_NAME_CHARS, + ); + const avatar = profile.avatar ?? user.avatarUrl ?? null; + + const expiresAt = Date.now() + ROOM_PASS_TTL_MS; + const pass = await signPass( + { + room: strategy.publicId, + uid: await opaqueUserId(user._id), + name, + avatar: + avatar !== null && avatar.length <= MAX_AVATAR_URL_CHARS + ? avatar + : null, + role, + exp: expiresAt, + }, + secret, + ); + // PRESENCE_URL is the Worker's https address; sockets need ws(s). + const socketBase = baseUrl.replace(/^http/, "ws").replace(/\/+$/, ""); + return { + url: `${socketBase}/v1/rooms/${strategy.publicId}`, + pass, + expiresAt, + }; + }, +}); + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max); +} + +/** Stable per user, but reveals nothing about the Convex row. */ +async function opaqueUserId(userId: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`icarus-presence:${userId}`), + ); + return Array.from(new Uint8Array(digest).slice(0, 12)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/convex/referencedAssetIds.test.ts b/convex/referencedAssetIds.test.ts new file mode 100644 index 00000000..1dd3719e --- /dev/null +++ b/convex/referencedAssetIds.test.ts @@ -0,0 +1,204 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { oneLineupPayload } from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const listReferencedAssetIds = makeFunctionReference<"query">( + "images:listReferencedAssetIds", +); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; +const strategyPublicId = "referenced-assets-strategy"; +const pagePublicId = "referenced-assets-page"; +// Every image the tests' content ever showed, and one it never did. +const asked = { + strategyPublicId, + assetPublicIds: ["removed", "shown", "shot", "never-placed"], +}; + +function identity(subject: string) { + return { + issuer: "https://referenced-assets.test", + subject, + tokenIdentifier: `referenced-assets|${subject}`, + name: subject, + }; +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +async function user(t: RootHarness, subject: string): Promise { + const harness = t.withIdentity(identity(subject)); + await harness.mutation(ensureCurrentUser, protocol); + return harness; +} + +let opCounter = 0; + +async function apply(owner: Harness, ops: Array>) { + const result = (await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId, + clientId: "client", + ops: ops.map((op) => ({ opId: `op-${++opCounter}`, ...op })), + })) as { results: Array<{ status: string }> }; + expect(result.results.map((entry) => entry.status)).toEqual( + ops.map(() => "applied"), + ); +} + +/// A strategy whose page shows 'shown' as an image and 'shot' in a lineup, +/// and once showed 'removed'. +async function seed(t: RootHarness): Promise { + const owner = await user(t, "owner"); + await owner.mutation(createStrategy, { + ...protocol, + publicId: strategyPublicId, + name: "Referenced assets", + mapData: "ascent", + initialPagePublicId: pagePublicId, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + await apply(owner, [ + { + type: "element.add", + elementPublicId: "shown", + pagePublicId, + payload: imagePayload("shown"), + sortIndex: 0, + }, + { + type: "element.add", + elementPublicId: "removed", + pagePublicId, + payload: imagePayload("removed"), + sortIndex: 1, + }, + { + type: "lineup.add", + lineupPublicId: "k", + pagePublicId, + payload: oneLineupPayload("k", { images: [{ id: "shot" }] }), + sortIndex: 0, + }, + ]); + await apply(owner, [ + { + type: "element.delete", + elementPublicId: "removed", + pagePublicId, + expectedElementRevision: 1, + }, + ]); + return owner; +} + +describe("images:listReferencedAssetIds", () => { + test("answers which of the images asked about the strategy's content shows, deleted content left out", async () => { + const t = convexTest(schema, modules); + await t.run(markAssetReferencesReady); + const owner = await seed(t); + + expect( + await owner.query(listReferencedAssetIds, asked), + ).toEqual(["shot", "shown"]); + }); + + test("reads a bounded number of rows: it answers at most 100 images at once", async () => { + const t = convexTest(schema, modules); + await t.run(markAssetReferencesReady); + const owner = await seed(t); + const many = Array.from({ length: 100 }, (_, index) => `image-${index}`); + + expect( + await owner.query(listReferencedAssetIds, { + strategyPublicId, + assetPublicIds: [...many.slice(1), "shown"], + }), + ).toEqual(["shown"]); + await expect( + owner.query(listReferencedAssetIds, { + strategyPublicId, + assetPublicIds: [...many, "shown"], + }), + ).rejects.toThrow(/at most 100/); + }); + + test("is null until the reference backfill has finished", async () => { + const t = convexTest(schema, modules); + const owner = await seed(t); + + expect( + await owner.query(listReferencedAssetIds, asked), + ).toBeNull(); + }); + + test("answers anyone who can view the strategy, and no one else", async () => { + const t = convexTest(schema, modules); + await t.run(markAssetReferencesReady); + const owner = await seed(t); + const viewer = await user(t, "viewer"); + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "viewer-token", + role: "viewer", + }); + await viewer.mutation(redeemShare, { ...protocol, token: "viewer-token" }); + const stranger = await user(t, "stranger"); + + expect( + await viewer.query(listReferencedAssetIds, asked), + ).toEqual(["shot", "shown"]); + await expect( + stranger.query(listReferencedAssetIds, asked), + ).rejects.toThrow(); + }); + + test("a client that checks references apart says so when it reads a full snapshot", async () => { + const t = convexTest(schema, modules); + await t.run(markAssetReferencesReady); + const owner = await seed(t); + + const snapshot = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId, + acceptsTrashedPagesLeftOut: true, + })) as { pages: Array<{ publicId: string }> }; + expect(snapshot.pages.map((page) => page.publicId)).toEqual([ + pagePublicId, + ]); + }); +}); diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 00000000..7d274020 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,322 @@ +// convex/schema.ts +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; +import { + elementPayloadKindValidator, + elementPayloadValidator, + lineupPayloadKindValidator, + lineupPayloadValidator, + mapThemePaletteValidator, + strategySettingsValidator, +} from "./lib/payloadValidators"; + +export default defineSchema({ + users: defineTable({ + externalId: v.string(), // auth provider subject + displayName: v.string(), + avatarUrl: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_externalId", ["externalId"]), + folders: defineTable({ + publicId: v.string(), + ownerId: v.id("users"), + name: v.string(), + parentFolderId: v.optional(v.id("folders")), + iconId: v.optional(v.number()), + iconCodePoint: v.optional(v.number()), + iconFontFamily: v.optional(v.string()), + iconFontPackage: v.optional(v.string()), + color: v.optional(v.string()), + customColorValue: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_ownerId", ["ownerId"]) + .index("by_parentFolderId", ["parentFolderId"]), + strategies: defineTable({ + publicId: v.string(), + ownerId: v.id("users"), + folderId: v.optional(v.id("folders")), + name: v.string(), + mapData: v.string(), + revision: v.number(), + themeProfileId: v.optional(v.string()), + themeOverridePalette: v.optional(mapThemePaletteValidator), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_ownerId", ["ownerId"]) + .index("by_folderId", ["folderId"]), + pages: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + name: v.string(), + isAutoNamed: v.optional(v.boolean()), + sortIndex: v.number(), + isAttack: v.boolean(), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + // Set when the page is deleted: it is in the trash, hidden from every + // read and refusing changes, with its content kept so it can be + // restored. Purged once PAGE_TRASH_RETENTION_MS has passed (see + // lib/entities.ts). Absent on every live page. + deletedAt: v.optional(v.number()), + // Who deleted it, while it is in the trash; shown in Recently deleted. + // Absent on live pages and on pages trashed before it was recorded. + deletedBy: v.optional(v.id("users")), + }) + .index("by_publicId", ["publicId"]) + .index("by_strategyId", ["strategyId"]) + // Live pages only: `.eq("deletedAt", undefined)` matches a missing field. + .index("by_strategyId_and_deletedAt", ["strategyId", "deletedAt"]) + .index("by_deletedAt", ["deletedAt"]), + pageContents: defineTable({ + pageId: v.id("pages"), + settings: v.optional(strategySettingsValidator), + revision: v.number(), + createdAt: v.number(), + updatedAt: v.number(), + }).index("by_pageId", ["pageId"]), + elements: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + elementType: elementPayloadKindValidator, + payloadKind: elementPayloadKindValidator, + payloadVersion: v.number(), + payload: elementPayloadValidator, + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_publicId", ["publicId"]) + .index("by_pageId", ["pageId"]) + .index("by_strategyId", ["strategyId"]) + // Reads one element type without touching the rest (large text rows). + .index("by_strategyId_and_elementType", ["strategyId", "elementType"]) + .index("by_deleted_and_updatedAt", ["deleted", "updatedAt"]), + lineups: defineTable({ + publicId: v.string(), + strategyId: v.id("strategies"), + pageId: v.id("pages"), + // One row per lineup group: the lineups on one page joined through + // shared spots, holding its origins, landings and links. Keyed by the + // group's id (payload.data.id). A key is unique within its strategy, not + // across strategies: a strategy copied on a device keeps its original's + // lineup ids. + // This shape replaced the origin/landing/link rows with no migration: + // production and dev held no lineup rows when it shipped (2026-10-02). + payloadKind: lineupPayloadKindValidator, + payloadVersion: v.number(), + payload: lineupPayloadValidator, + sortIndex: v.number(), + revision: v.number(), + deleted: v.boolean(), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_strategyId_and_publicId", ["strategyId", "publicId"]) + .index("by_pageId", ["pageId"]) + .index("by_deleted_and_updatedAt", ["deleted", "updatedAt"]), + // The agents a live lineup group starts from: one small row per origin in + // the group, kept in step with every lineup write (see + // lib/strategyAgentSummary.ts). The strategy's agent summary reads these + // instead of the lineup rows, whose image lists can make reading them all + // exceed a transaction's limits. + lineupAgents: defineTable({ + strategyId: v.id("strategies"), + pageId: v.id("pages"), + lineupId: v.id("lineups"), + originId: v.string(), + agentType: v.string(), + }) + .index("by_strategyId", ["strategyId"]) + .index("by_lineupId", ["lineupId"]), + // The lineups and spots each live lineup group holds, one small row per + // item (`origin:`, `landing:` or `link:`), kept in step with + // every lineup write (see lib/lineupItems.ts). A write is refused if it + // would put an item in two groups of a page; the check reads these rows + // instead of the lineup rows, whose image lists can be large. + lineupItems: defineTable({ + pageId: v.id("pages"), + lineupId: v.id("lineups"), + item: v.string(), + }) + .index("by_pageId_and_item", ["pageId", "item"]) + .index("by_lineupId", ["lineupId"]), + // Which content rows show which images: one small row per (element or + // lineup row, image id it shows), kept in step with every content write + // (see lib/assetReferences.ts). Media cleanup checks an image's references + // here with an indexed lookup instead of reading every element and lineup + // of the strategy, which can exceed Convex's per-transaction read limit. + assetReferences: defineTable({ + strategyId: v.id("strategies"), + assetPublicId: v.string(), + pageId: v.id("pages"), + elementId: v.optional(v.id("elements")), + lineupId: v.optional(v.id("lineups")), + // Mirrors the content row's `deleted`: a tombstone still references its + // image until it is purged, since undo may restore it. + deleted: v.boolean(), + }) + .index("by_strategyId_and_assetPublicId", ["strategyId", "assetPublicId"]) + .index("by_strategyId_and_assetPublicId_and_deleted", [ + "strategyId", + "assetPublicId", + "deleted", + ]) + .index("by_strategyId_and_deleted", ["strategyId", "deleted"]) + .index("by_elementId", ["elementId"]) + .index("by_lineupId", ["lineupId"]), + // Images that may have lost their last reference (their content was + // purged), written in the same transaction as the purge and checked in + // small scheduled batches, so a failed check is retried, never lost. + assetReclaimCandidates: defineTable({ + strategyId: v.id("strategies"), + assetPublicId: v.string(), + createdAt: v.number(), + }) + .index("by_createdAt", ["createdAt"]) + .index("by_strategyId_and_assetPublicId", ["strategyId", "assetPublicId"]), + // One row per one-off data backfill that has finished. Code that relies on + // backfilled data checks for its row first (see assetReferencesReady). + completedBackfills: defineTable({ + name: v.string(), + completedAt: v.number(), + }).index("by_name", ["name"]), + strategyCollaborators: defineTable({ + strategyId: v.id("strategies"), + userId: v.id("users"), + role: v.union(v.literal("editor"), v.literal("viewer")), + invitedByUserId: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_strategyId", ["strategyId"]) + .index("by_userId", ["userId"]) + .index("by_strategyId_userId", ["strategyId", "userId"]), + folderCollaborators: defineTable({ + folderId: v.id("folders"), + userId: v.id("users"), + role: v.union(v.literal("editor"), v.literal("viewer")), + invitedByUserId: v.optional(v.id("users")), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_folderId", ["folderId"]) + .index("by_userId", ["userId"]) + .index("by_folderId_userId", ["folderId", "userId"]), + shareLinks: defineTable({ + token: v.string(), + targetType: v.union(v.literal("strategy"), v.literal("folder")), + strategyId: v.optional(v.id("strategies")), + folderId: v.optional(v.id("folders")), + role: v.union(v.literal("editor"), v.literal("viewer")), + createdByUserId: v.id("users"), + revokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_token", ["token"]) + .index("by_strategyId", ["strategyId"]) + .index("by_folderId", ["folderId"]), + inviteTokens: defineTable({ + token: v.string(), + strategyId: v.id("strategies"), + role: v.union(v.literal("editor"), v.literal("viewer")), + createdByUserId: v.id("users"), + redeemedByUserId: v.optional(v.id("users")), + expiresAt: v.optional(v.number()), + revokedAt: v.optional(v.number()), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_token", ["token"]) + .index("by_strategyId", ["strategyId"]), + imageAssets: defineTable({ + publicId: v.string(), + uploadAttemptPublicId: v.optional(v.string()), + provider: v.optional(v.union(v.literal("convex"), v.literal("r2"))), + strategyId: v.optional(v.id("strategies")), + createdByUserId: v.optional(v.id("users")), + storageId: v.optional(v.id("_storage")), + objectKey: v.optional(v.string()), + uploadStatus: v.optional( + v.union( + v.literal("pending"), + v.literal("active"), + v.literal("failed"), + v.literal("deleted"), + ), + ), + fileExtension: v.optional(v.string()), + mimeType: v.optional(v.string()), + width: v.optional(v.number()), + height: v.optional(v.number()), + byteSize: v.optional(v.number()), + etag: v.optional(v.string()), + uploadedAt: v.optional(v.number()), + // When the presigned PUT URL for this upload attempt stops working. Until + // then the bytes can still land, so they are not deleted before it. + uploadUrlExpiresAt: v.optional(v.number()), + deletedAt: v.optional(v.number()), + cleanupClaimedAt: v.optional(v.number()), + createdAt: v.optional(v.number()), + updatedAt: v.optional(v.number()), + // Legacy rows may still have a storagePath that can help infer the extension. + storagePath: v.optional(v.string()), + }) + .index("by_publicId", ["publicId"]) + .index("by_uploadAttemptPublicId", ["uploadAttemptPublicId"]) + .index("by_strategyId", ["strategyId"]) + .index("by_strategyId_and_uploadStatus_and_updatedAt", [ + "strategyId", + "uploadStatus", + "updatedAt", + ]) + .index("by_strategyId_and_publicId", ["strategyId", "publicId"]) + .index("by_strategyId_and_publicId_and_uploadStatus", [ + "strategyId", + "publicId", + "uploadStatus", + ]) + .index("by_uploadStatus_and_updatedAt", ["uploadStatus", "updatedAt"]) + .index("by_storageId", ["storageId"]) + .index("by_objectKey", ["objectKey"]), + // Derived: which agents each strategy uses, kept current by ops.applyBatch + // so the folder tree can summarise a folder without reading its elements. + strategyAgentSummaries: defineTable({ + strategyId: v.id("strategies"), + agentTypes: v.array(v.string()), + updatedAt: v.number(), + }).index("by_strategyId", ["strategyId"]), + operationEvents: defineTable({ + strategyId: v.id("strategies"), + pageId: v.optional(v.id("pages")), + clientId: v.string(), + opId: v.string(), + opType: v.string(), + status: v.union( + v.literal("applied"), + v.literal("noop"), + v.literal("rejected"), + v.literal("failed"), + ), + reason: v.optional(v.string()), + code: v.optional(v.string()), + rawCode: v.optional(v.string()), + message: v.optional(v.string()), + expectedRevision: v.optional(v.number()), + appliedRevision: v.optional(v.number()), + createdAt: v.number(), + }) + .index("by_strategyId", ["strategyId"]) + .index("by_createdAt", ["createdAt"]) + .index("by_strategyId_clientId_opId", ["strategyId", "clientId", "opId"]), +}); diff --git a/convex/shareLinkViewing.test.ts b/convex/shareLinkViewing.test.ts new file mode 100644 index 00000000..d81fb8b5 --- /dev/null +++ b/convex/shareLinkViewing.test.ts @@ -0,0 +1,195 @@ +import { convexTest } from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createFolder = makeFunctionReference<"mutation">("folders:create"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const updateStrategy = makeFunctionReference<"mutation">("strategies:update"); +const getStrategyShell = makeFunctionReference<"query">("strategy:getShell"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const getPageSnapshot = makeFunctionReference<"query">("page:getSnapshot"); +const resolveShare = makeFunctionReference<"query">("shares:resolve"); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const revokeShare = makeFunctionReference<"mutation">("shares:revoke"); + +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; + +function identity(subject: "owner" | "stranger") { + return { + issuer: "https://share-link-viewing.test", + subject, + tokenIdentifier: `share-link-viewing|${subject}`, + name: subject, + }; +} + +async function createHarness() { + const t = convexTest(schema, modules); + const owner = t.withIdentity(identity("owner")); + const stranger = t.withIdentity(identity("stranger")); + await owner.mutation(ensureCurrentUser, protocol); + await stranger.mutation(ensureCurrentUser, protocol); + return { t, owner, stranger }; +} + +async function seedStrategy( + owner: ReturnType["withIdentity"]>, + strategyPublicId: string, + pagePublicId: string, + folderPublicId?: string, +) { + await owner.mutation(createStrategy, { + ...protocol, + publicId: strategyPublicId, + name: "Shared Strategy", + mapData: "ascent", + folderPublicId, + initialPagePublicId: pagePublicId, + initialPageName: "Page 1", + initialPageIsAttack: true, + }); +} + +describe("viewing through a share link without an account", () => { + test("a live strategy link lets anyone holding it read, never write", async () => { + const { t, owner, stranger } = await createHarness(); + const strategyPublicId = "linked-strategy"; + const pagePublicId = "linked-page"; + const shareToken = "linked-token"; + await seedStrategy(owner, strategyPublicId, pagePublicId); + await seedStrategy(owner, "other-strategy", "other-page"); + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: shareToken, + role: "editor", + }); + + await expect(t.query(resolveShare, { token: shareToken })).resolves.toEqual( + { targetType: "strategy", strategyPublicId, role: "editor" }, + ); + await expect( + t.query(getStrategyShell, { ...protocol, strategyPublicId }), + ).rejects.toThrow("Unauthenticated"); + // Even an editor link lets a holder who has not redeemed it only view. + await expect( + t.query(getStrategyShell, { ...protocol, strategyPublicId, shareToken }), + ).resolves.toMatchObject({ + header: { publicId: strategyPublicId, role: "viewer" }, + pages: [{ publicId: pagePublicId }], + }); + await expect( + t.query(getFullSnapshot, { ...protocol, strategyPublicId, shareToken }), + ).resolves.toMatchObject({ header: { role: "viewer" } }); + await expect( + t.query(getPageSnapshot, { + ...protocol, + strategyPublicId, + pagePublicId, + shareToken, + }), + ).resolves.toMatchObject({ page: { publicId: pagePublicId } }); + + // The link opens its own strategy and nothing else. + await expect( + t.query(getStrategyShell, { + ...protocol, + strategyPublicId: "other-strategy", + shareToken, + }), + ).rejects.toThrow("Unauthenticated"); + await expect( + stranger.query(getStrategyShell, { + ...protocol, + strategyPublicId: "other-strategy", + shareToken, + }), + ).rejects.toThrow("Forbidden"); + + // A signed-in holder reads as a viewer; the owner keeps their own role. + await expect( + stranger.query(getStrategyShell, { + ...protocol, + strategyPublicId, + shareToken, + }), + ).resolves.toMatchObject({ header: { role: "viewer" } }); + await expect( + owner.query(getStrategyShell, { + ...protocol, + strategyPublicId, + shareToken, + }), + ).resolves.toMatchObject({ header: { role: "owner" } }); + await expect( + stranger.mutation(updateStrategy, { + ...protocol, + strategyPublicId, + expectedRevision: 0, + name: "A link holder must not write", + }), + ).rejects.toThrow("Forbidden"); + + await owner.mutation(revokeShare, { + ...protocol, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: shareToken, + }); + await expect( + t.query(resolveShare, { token: shareToken }), + ).rejects.toThrow("Share link revoked"); + await expect( + t.query(getStrategyShell, { ...protocol, strategyPublicId, shareToken }), + ).rejects.toThrow("Share link revoked"); + await expect( + stranger.query(getStrategyShell, { + ...protocol, + strategyPublicId, + shareToken, + }), + ).rejects.toThrow("Share link revoked"); + }); + + test("a folder link names only its kind and opens nothing signed out", async () => { + const { t, owner } = await createHarness(); + await owner.mutation(createFolder, { + ...protocol, + publicId: "linked-folder", + name: "Linked folder", + }); + await seedStrategy(owner, "foldered-strategy", "foldered-page", "linked-folder"); + await owner.mutation(createShare, { + ...protocol, + targetType: "folder", + targetPublicId: "linked-folder", + token: "folder-token", + role: "viewer", + }); + + await expect( + t.query(resolveShare, { token: "folder-token" }), + ).resolves.toEqual({ targetType: "folder", role: "viewer" }); + await expect( + t.query(getStrategyShell, { + ...protocol, + strategyPublicId: "foldered-strategy", + shareToken: "folder-token", + }), + ).rejects.toThrow("Unauthenticated"); + await expect( + t.query(resolveShare, { token: "no-such-token" }), + ).rejects.toThrow("Share link not found"); + }); +}); diff --git a/convex/shares.ts b/convex/shares.ts new file mode 100644 index 00000000..5add7a2f --- /dev/null +++ b/convex/shares.ts @@ -0,0 +1,369 @@ +import type { QueryCtx, MutationCtx } from "./_generated/server"; +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { + assertFolderRole, + assertStrategyRole, + higherCollaboratorRole, + requireCurrentUser, + type CollaboratorRole, +} from "./lib/auth"; +import { getFolderByPublicId, getStrategyByPublicId } from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + notFoundError, + errorWithCode, + conflictError, +} from "./lib/errors"; +import { + accessRoleValidator, + okResultValidator, +} from "./lib/publicValidators"; + +const targetTypeValidator = v.union(v.literal("strategy"), v.literal("folder")); +const collaboratorRoleValidator = v.union(v.literal("viewer"), v.literal("editor")); +type AnyCtx = QueryCtx | MutationCtx; + +async function resolveTarget( + ctx: AnyCtx, + targetType: "strategy" | "folder", + targetPublicId: string, +) { + if (targetType === "strategy") { + const strategy = await getStrategyByPublicId(ctx, targetPublicId); + return { targetType, strategy, folder: null }; + } + + const folder = await getFolderByPublicId(ctx, targetPublicId); + return { targetType, strategy: null, folder }; +} + +export const list = query({ + args: { + targetType: targetTypeValidator, + targetPublicId: v.string(), + }, + returns: v.array( + v.object({ + token: v.string(), + role: collaboratorRoleValidator, + createdAt: v.number(), + revokedAt: v.union(v.number(), v.null()), + }), + ), + handler: async (ctx, args) => { + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + await assertStrategyRole(ctx, resolved.strategy, "owner"); + } else if (resolved.folder !== null) { + await assertFolderRole(ctx, resolved.folder, "owner"); + } + + const links = + resolved.strategy !== null + ? await ctx.db + .query("shareLinks") + .withIndex("by_strategyId", (q) => q.eq("strategyId", resolved.strategy!._id)) + .collect() + : await ctx.db + .query("shareLinks") + .withIndex("by_folderId", (q) => q.eq("folderId", resolved.folder!._id)) + .collect(); + + return links + .sort((a, b) => b.createdAt - a.createdAt) + .map((link) => ({ + token: link.token, + role: link.role, + createdAt: link.createdAt, + revokedAt: link.revokedAt ?? null, + })); + }, +}); + +export const create = mutation({ + args: { + ...cloudProtocolArgs, + targetType: targetTypeValidator, + targetPublicId: v.string(), + token: v.string(), + role: collaboratorRoleValidator, + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + await assertStrategyRole(ctx, resolved.strategy, "owner"); + } else if (resolved.folder !== null) { + await assertFolderRole(ctx, resolved.folder, "owner"); + } + + const existingLink = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (existingLink !== null) { + throw conflictError(`Share token already exists: ${args.token}`); + } + + await ctx.db.insert("shareLinks", { + token: args.token, + targetType: args.targetType, + strategyId: resolved.strategy?._id, + folderId: resolved.folder?._id, + role: args.role, + createdByUserId: user._id, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true } as const; + }, +}); + +export const revoke = mutation({ + args: { + ...cloudProtocolArgs, + targetType: targetTypeValidator, + targetPublicId: v.string(), + token: v.string(), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const resolved = await resolveTarget(ctx, args.targetType, args.targetPublicId); + + if (resolved.strategy !== null) { + await assertStrategyRole(ctx, resolved.strategy, "owner"); + } else if (resolved.folder !== null) { + await assertFolderRole(ctx, resolved.folder, "owner"); + } + + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (link === null) { + throw notFoundError("Share link", args.token); + } + + if ( + (resolved.strategy !== null && link.strategyId !== resolved.strategy._id) || + (resolved.folder !== null && link.folderId !== resolved.folder._id) + ) { + throw notFoundError("Share link", args.token); + } + + await ctx.db.patch(link._id, { + revokedAt: Date.now(), + updatedAt: Date.now(), + }); + + return { ok: true } as const; + }, +}); + +/** + * What a share link opens, for someone who holds it but has not redeemed it + * (usually because they are signed out). Needs no account: the token is the + * credential, and a strategy link lets its holder view that strategy by + * passing the token to the read queries. Folders still need an account. + */ +export const resolve = query({ + args: { + token: v.string(), + }, + returns: v.union( + v.object({ + targetType: v.literal("strategy"), + strategyPublicId: v.string(), + role: collaboratorRoleValidator, + }), + v.object({ + targetType: v.literal("folder"), + role: collaboratorRoleValidator, + }), + ), + handler: async (ctx, args) => { + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + if (link === null) { + throw notFoundError("Share link", args.token); + } + if (link.revokedAt !== undefined) { + throw errorWithCode("SHARE_LINK_REVOKED", "Share link revoked"); + } + + if (link.targetType === "folder") { + return { targetType: "folder", role: link.role } as const; + } + const strategy = + link.strategyId === undefined ? null : await ctx.db.get(link.strategyId); + if (strategy === null) { + throw notFoundError("Share link", args.token); + } + return { + targetType: "strategy", + strategyPublicId: strategy.publicId, + role: link.role, + } as const; + }, +}); + +export const redeem = mutation({ + args: { + ...cloudProtocolArgs, + token: v.string(), + }, + returns: v.union( + v.object({ + ok: v.literal(true), + targetType: v.literal("strategy"), + strategyPublicId: v.string(), + folderPublicId: v.union(v.string(), v.null()), + role: accessRoleValidator, + // True when the caller could already open the target (they own it, or + // already held this role or a higher one), so the link granted nothing. + // Always sent. Optional only so a client built against this contract + // still decodes a deployment that predates the field (web deploys on + // merge; the Convex deployment is updated separately). + alreadyHadAccess: v.optional(v.boolean()), + }), + v.object({ + ok: v.literal(true), + targetType: v.literal("folder"), + folderPublicId: v.string(), + role: accessRoleValidator, + alreadyHadAccess: v.optional(v.boolean()), + }), + ), + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + const link = await ctx.db + .query("shareLinks") + .withIndex("by_token", (q) => q.eq("token", args.token)) + .first(); + + if (link === null) { + throw notFoundError("Share link", args.token); + } + + if (link.revokedAt !== undefined) { + throw errorWithCode("SHARE_LINK_REVOKED", "Share link revoked"); + } + + if (link.targetType === "strategy") { + const strategy = link.strategyId === undefined ? null : await ctx.db.get(link.strategyId); + if (strategy === null) { + throw notFoundError( + "Strategy", + link.strategyId === undefined ? "unknown" : link.strategyId, + ); + } + + let redeemedRole: CollaboratorRole = link.role; + let alreadyHadAccess = strategy.ownerId === user._id; + if (strategy.ownerId !== user._id) { + const existingMembership = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId_userId", (q) => + q.eq("strategyId", strategy._id).eq("userId", user._id), + ) + .first(); + + if (existingMembership === null) { + await ctx.db.insert("strategyCollaborators", { + strategyId: strategy._id, + userId: user._id, + role: link.role, + invitedByUserId: link.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + redeemedRole = higherCollaboratorRole( + existingMembership.role, + link.role, + ); + alreadyHadAccess = redeemedRole === existingMembership.role; + if (!alreadyHadAccess) { + await ctx.db.patch(existingMembership._id, { + role: redeemedRole, + updatedAt: Date.now(), + }); + } + } + } + + const folder = + strategy.folderId === undefined ? null : await ctx.db.get(strategy.folderId); + + return { + ok: true, + targetType: "strategy", + strategyPublicId: strategy.publicId, + folderPublicId: folder?.publicId ?? null, + role: strategy.ownerId === user._id ? "owner" : redeemedRole, + alreadyHadAccess, + } as const; + } + + const folder = link.folderId === undefined ? null : await ctx.db.get(link.folderId); + if (folder === null) { + throw notFoundError("Folder", args.token); + } + + let redeemedRole: CollaboratorRole = link.role; + let alreadyHadAccess = folder.ownerId === user._id; + if (folder.ownerId !== user._id) { + const existingMembership = await ctx.db + .query("folderCollaborators") + .withIndex("by_folderId_userId", (q) => + q.eq("folderId", folder._id).eq("userId", user._id), + ) + .first(); + + if (existingMembership === null) { + await ctx.db.insert("folderCollaborators", { + folderId: folder._id, + userId: user._id, + role: link.role, + invitedByUserId: link.createdByUserId, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } else { + redeemedRole = higherCollaboratorRole( + existingMembership.role, + link.role, + ); + alreadyHadAccess = redeemedRole === existingMembership.role; + if (!alreadyHadAccess) { + await ctx.db.patch(existingMembership._id, { + role: redeemedRole, + updatedAt: Date.now(), + }); + } + } + } + + return { + ok: true, + targetType: "folder", + folderPublicId: folder.publicId, + role: folder.ownerId === user._id ? "owner" : redeemedRole, + alreadyHadAccess, + } as const; + }, +}); diff --git a/convex/strategies.ts b/convex/strategies.ts new file mode 100644 index 00000000..11c7e7f5 --- /dev/null +++ b/convex/strategies.ts @@ -0,0 +1,1065 @@ +import { mutation, query } from "./_generated/server"; +import { getConvexSize, v, type Value } from "convex/values"; +import { + agentTypesOf, + deleteStrategyAgentSummary, + lineupAgentsOf, + refreshStrategyAgentSummary, + storeStrategyAgentSummary, +} from "./lib/strategyAgentSummary"; +import { + collectAssetIdFromElementPayload, + collectAssetIdsFromLineupPayload, + copyActiveAssetToStrategy, +} from "./lib/imageAssets"; +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { + assertFolderRole, + assertStrategyRole, + getEffectiveFolderRoleForUser, + getEffectiveStrategyRoleForUser, + higherRole, + requireCurrentUser, +} from "./lib/auth"; +import type { StrategyRole } from "./lib/auth"; +import { lineupGroupItems } from "./lib/lineupItems"; +import { + getFolderByPublicId, + getStrategyByPublicId, + livePagesQuery, +} from "./lib/entities"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + mapThemePaletteValidator, + strategySettingsValidator, +} from "./lib/payloadValidators"; +import { + conflictError, + errorWithCode, + forbiddenError, +} from "./lib/errors"; +import { + assetIdsOfRow, + insertAssetReferences, + type ReferencingElement, +} from "./lib/assetReferences"; +import { purgeDeletedPageOrphansRef } from "./maintenance"; +import { markDeletedStrategyImageAssetsRef } from "./images"; +import { + createResultValidator, + okResultValidator, + revisionResultValidator, + strategyHeaderValidator, + strategySummaryValidator, +} from "./lib/publicValidators"; + +type StrategyScope = "owned" | "shared" | "all"; + +type StrategyCreateInput = { + publicId: string; + name: string; + mapData: string; + folderPublicId?: string; + themeProfileId?: string; + themeOverridePalette?: Doc<"strategies">["themeOverridePalette"]; +}; + +type InitialPageInput = { + publicId: string; + name: string; + isAutoNamed?: boolean; + isAttack: boolean; + settings?: Doc<"pageContents">["settings"]; +}; + +function createPublicId(): string { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const random = Math.floor(Math.random() * 16); + const value = char === "x" ? random : (random & 0x3) | 0x8; + return value.toString(16); + }); +} + +const strategyScopeValidator = v.optional( + v.union(v.literal("owned"), v.literal("shared"), v.literal("all")), +); + +function matchesScope( + ownerId: Id<"users">, + userId: Id<"users">, + scope: StrategyScope, +): boolean { + if (scope === "all") { + return true; + } + if (scope === "owned") { + return ownerId === userId; + } + return ownerId !== userId; +} + +async function summarizeStrategies( + ctx: QueryCtx, + strategies: Doc<"strategies">[], + userId: Id<"users">, +) { + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const strategyRoleById = new Map, "viewer" | "editor">(); + for (const membership of memberships) { + strategyRoleById.set(membership.strategyId, membership.role); + } + + const folderById = new Map, Promise | null>>(); + const folderRoleById = new Map< + Id<"folders">, + Promise + >(); + + const getFolder = (folderId: Id<"folders">): Promise | null> => { + const cached = folderById.get(folderId); + if (cached !== undefined) { + return cached; + } + + const promise = ctx.db.get(folderId); + folderById.set(folderId, promise); + return promise; + }; + + const getFolderRole = ( + folderId: Id<"folders">, + ): Promise => { + const cached = folderRoleById.get(folderId); + if (cached !== undefined) { + return cached; + } + + const promise = (async () => { + const folder = await getFolder(folderId); + if (folder === null) { + return null; + } + return await getEffectiveFolderRoleForUser(ctx, folder, userId); + })(); + folderRoleById.set(folderId, promise); + return promise; + }; + + const orderedStrategies = [...strategies].sort((a, b) => b.updatedAt - a.updatedAt); + + return await Promise.all(orderedStrategies.map(async (strategy): Promise<{ + publicId: string; + name: string; + mapData: string; + revision: number; + createdAt: number; + updatedAt: number; + role: StrategyRole; + attackLabel: "Unknown" | "Mixed" | "Attack" | "Defend"; + folderPublicId: string | null; + themeProfileId: string | null; + themeOverridePalette: + | NonNullable["themeOverridePalette"]> + | null; + }> => { + const pagesPromise = livePagesQuery(ctx, strategy._id).take(100); + const folderPromise = + strategy.folderId === undefined + ? Promise.resolve(null) + : getFolder(strategy.folderId); + const folderRolePromise = + strategy.folderId === undefined || strategy.ownerId === userId + ? Promise.resolve(null) + : getFolderRole(strategy.folderId); + const [pages, folder, folderRole] = await Promise.all([ + pagesPromise, + folderPromise, + folderRolePromise, + ]); + let attackLabel: "Unknown" | "Mixed" | "Attack" | "Defend" = "Unknown"; + if (pages.length > 0) { + const first = pages[0]!.isAttack; + const mixed = pages.some((page) => page.isAttack !== first); + attackLabel = mixed ? "Mixed" : first ? "Attack" : "Defend"; + } + + let role: StrategyRole; + if (strategy.ownerId === userId) { + role = "owner"; + } else { + const directRole = strategyRoleById.get(strategy._id); + role = higherRole(directRole ?? null, folderRole) ?? "viewer"; + } + + return { + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + revision: strategy.revision, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + role, + attackLabel, + folderPublicId: + strategy.folderId === undefined ? null : (folder?.publicId ?? null), + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + }; + })); +} + +async function listStrategiesInFolder( + ctx: QueryCtx, + folderId: Id<"folders"> | undefined, + userId: Id<"users">, + scope: StrategyScope, +) { + let candidates: Doc<"strategies">[]; + if (folderId !== undefined) { + candidates = await ctx.db + .query("strategies") + .withIndex("by_folderId", (q) => q.eq("folderId", folderId)) + .collect(); + } else if (scope === "shared") { + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + candidates = shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== userId && + strategy.folderId === undefined, + ); + } else { + candidates = await ctx.db + .query("strategies") + .withIndex("by_ownerId", (q) => q.eq("ownerId", userId)) + .collect(); + candidates = candidates.filter( + (strategy) => strategy.folderId === undefined, + ); + + if (scope === "all") { + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", userId)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + candidates.push( + ...shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== userId && + strategy.folderId === undefined, + ), + ); + } + } + + const dedup = new Map, Doc<"strategies">>(); + for (const strategy of candidates) { + if ( + matchesScope(strategy.ownerId, userId, scope) && + (await getEffectiveStrategyRoleForUser(ctx, strategy, userId)) !== null + ) { + dedup.set(strategy._id, strategy); + } + } + return Array.from(dedup.values()); +} + +async function resolveOwnedFolderId( + ctx: MutationCtx, + folderPublicId: string | undefined, + userId: Id<"users">, +) { + if (folderPublicId === undefined) { + return undefined; + } + const folder = await getFolderByPublicId(ctx, folderPublicId); + if (folder.ownerId !== userId) { + throw forbiddenError(); + } + return folder._id; +} + +async function assertInitialPagePublicIdAvailable( + ctx: MutationCtx, + pagePublicId: string, + allowedStrategyId?: Id<"strategies">, +) { + const existingPage = await ctx.db + .query("pages") + .withIndex("by_publicId", (q) => q.eq("publicId", pagePublicId)) + .first(); + if ( + existingPage !== null && + (allowedStrategyId === undefined || + existingPage.strategyId !== allowedStrategyId) + ) { + throw conflictError(`Page publicId already exists: ${pagePublicId}`); + } +} + +async function insertInitialPage( + ctx: MutationCtx, + args: { + strategyId: Id<"strategies">; + initialPage: InitialPageInput; + now: number; + }, +) { + const pageId = await ctx.db.insert("pages", { + publicId: args.initialPage.publicId, + strategyId: args.strategyId, + name: args.initialPage.name, + ...(args.initialPage.isAutoNamed === undefined + ? {} + : { isAutoNamed: args.initialPage.isAutoNamed }), + sortIndex: 0, + isAttack: args.initialPage.isAttack, + revision: 1, + createdAt: args.now, + updatedAt: args.now, + }); + await ctx.db.insert("pageContents", { + pageId, + settings: args.initialPage.settings, + revision: 1, + createdAt: args.now, + updatedAt: args.now, + }); +} + +async function createStrategyWithInitialPageRecord( + ctx: MutationCtx, + args: StrategyCreateInput, + userId: Id<"users">, + initialPage: InitialPageInput, +) { + const now = Date.now(); + const folderId = await resolveOwnedFolderId(ctx, args.folderPublicId, userId); + + const existing = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", args.publicId)) + .collect(); + const existingOwned = existing.find((item) => item.ownerId === userId); + if (existingOwned !== undefined) { + const pages = await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", existingOwned._id)) + .collect(); + if (pages.length === 0) { + await assertInitialPagePublicIdAvailable( + ctx, + initialPage.publicId, + existingOwned._id, + ); + await insertInitialPage(ctx, { + strategyId: existingOwned._id, + initialPage, + now, + }); + } + return { ok: true, reused: true } as const; + } + if (existing.length > 0) { + throw conflictError(`Strategy publicId already exists: ${args.publicId}`); + } + + await assertInitialPagePublicIdAvailable(ctx, initialPage.publicId); + + const strategyId = await ctx.db.insert("strategies", { + publicId: args.publicId, + ownerId: userId, + folderId, + name: args.name, + mapData: args.mapData, + revision: 0, + themeProfileId: args.themeProfileId, + themeOverridePalette: args.themeOverridePalette, + createdAt: now, + updatedAt: now, + }); + + await insertInitialPage(ctx, { + strategyId, + initialPage, + now, + }); + + return { ok: true } as const; +} + +export const listForFolder = query({ + args: { + folderPublicId: v.optional(v.string()), + scope: strategyScopeValidator, + }, + returns: v.array(strategySummaryValidator), + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + const scope = args.scope ?? "owned"; + + let folderId: Id<"folders"> | undefined; + if (args.folderPublicId !== undefined) { + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + await assertFolderRole(ctx, folder, "viewer"); + folderId = folder._id; + } + + const strategies = await listStrategiesInFolder( + ctx, + folderId, + user._id, + scope, + ); + return await summarizeStrategies(ctx, strategies, user._id); + }, +}); + +export const listSharedWithMe = query({ + args: {}, + returns: v.array(strategySummaryValidator), + handler: async (ctx) => { + const user = await requireCurrentUser(ctx); + const memberships = await ctx.db + .query("strategyCollaborators") + .withIndex("by_userId", (q) => q.eq("userId", user._id)) + .collect(); + const shared = await Promise.all( + memberships.map((membership) => ctx.db.get(membership.strategyId)), + ); + const strategies = shared.filter( + (strategy): strategy is Doc<"strategies"> => + strategy !== null && + strategy.ownerId !== user._id && + strategy.folderId === undefined, + ); + return await summarizeStrategies(ctx, strategies, user._id); + }, +}); + +export const getHeader = query({ + args: { + strategyPublicId: v.string(), + }, + returns: strategyHeaderValidator, + handler: async (ctx, args) => { + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const { role } = await assertStrategyRole(ctx, strategy, "viewer"); + + return { + publicId: strategy.publicId, + name: strategy.name, + mapData: strategy.mapData, + revision: strategy.revision, + createdAt: strategy.createdAt, + updatedAt: strategy.updatedAt, + themeProfileId: strategy.themeProfileId ?? null, + themeOverridePalette: strategy.themeOverridePalette ?? null, + role, + }; + }, +}); + +export const create = mutation({ + args: { + ...cloudProtocolArgs, + publicId: v.string(), + name: v.string(), + mapData: v.string(), + folderPublicId: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + themeOverridePalette: v.optional(mapThemePaletteValidator), + }, + returns: createResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + return await createStrategyWithInitialPageRecord(ctx, args, user._id, { + publicId: createPublicId(), + name: "Page 1", + isAutoNamed: true, + isAttack: true, + }); + }, +}); + +export const createWithInitialPage = mutation({ + args: { + ...cloudProtocolArgs, + publicId: v.string(), + name: v.string(), + mapData: v.string(), + initialPagePublicId: v.string(), + initialPageName: v.string(), + initialPageIsAutoNamed: v.optional(v.boolean()), + initialPageIsAttack: v.boolean(), + initialPageSettings: v.optional(strategySettingsValidator), + folderPublicId: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + themeOverridePalette: v.optional(mapThemePaletteValidator), + }, + returns: createResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const user = await requireCurrentUser(ctx); + return await createStrategyWithInitialPageRecord(ctx, args, user._id, { + publicId: args.initialPagePublicId, + name: args.initialPageName, + isAutoNamed: args.initialPageIsAutoNamed, + isAttack: args.initialPageIsAttack, + settings: args.initialPageSettings, + }); + }, +}); + +type LineupData = Doc<"lineups">["payload"]["data"]; + +function isJsonObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +type LineupPayload = Doc<"lineups">["payload"]; + +/// New ids for a copied strategy's lineups: one map shared by every row and +/// every id in it, so one source id always becomes one copy id. Ids the +/// source repeats within a group (a landing may take its lineup's id, a +/// link names its origin and landing) repeat in the copy the same way. +function lineupIdMap() { + const ids = new Map(); + return (id: string): string => { + let next = ids.get(id); + if (next === undefined) ids.set(id, (next = createPublicId())); + return next; + }; +} + +/// A lineup group row as the copy stores it: the group, its origins, +/// landings and links under new ids, each link naming its ends' new ids and +/// each marker's `lineUpID` following its spot. Everything else is kept as +/// it is. +function copiedLineupRow( + payload: LineupPayload, + newId: (id: string) => string, +): { publicId: string; payload: LineupPayload } { + const data = payload.data; + const idOf = (value: unknown) => (typeof value === "string" ? value : ""); + const entries = (value: unknown): unknown[] => + Array.isArray(value) ? value : []; + const copiedSpot = (spot: unknown, markerKey: "agent" | "ability") => { + if (!isJsonObject(spot)) return spot; + const id = newId(idOf(spot.id)); + const marker = spot[markerKey]; + return { + ...spot, + id, + ...(isJsonObject(marker) + ? { [markerKey]: { ...marker, lineUpID: id } } + : {}), + }; + }; + const copiedLink = (link: unknown) => + isJsonObject(link) + ? { + ...link, + id: newId(idOf(link.id)), + originId: newId(idOf(link.originId)), + landingId: newId(idOf(link.landingId)), + } + : link; + const id = newId(idOf(data.id)); + return { + publicId: id, + payload: { + ...payload, + data: { + ...data, + id, + origins: entries(data.origins).map((origin) => + copiedSpot(origin, "agent"), + ), + landings: entries(data.landings).map((landing) => + copiedSpot(landing, "ability"), + ), + links: entries(data.links).map(copiedLink), + } as LineupData, + }, + }; +} + +// A duplicate is one transaction, and Convex refuses a transaction past +// 16 MiB read or written, 4,096 index reads, or too many documents +// written. The copy reads the source's content once and writes it once, +// so its budget is what it reads: +// - bytes: each content row's stored size (deleted rows included, as +// read), and for each image the copy shows the most its asset copy can +// read (duplicateImageReadBytes), and each page and its settings. 12 MiB +// leaves 4 MiB of headroom. Measured on a local backend with 700 KiB +// rows: 23 rows (15.7 MiB) copied, 24 (16.4 MiB) failed on the read +// limit. +// - documents: pages (three each: read, and the page and settings rows +// written), content rows read, and reference rows written. This keeps +// writes far under the document limit, and caps the per-page settings +// reads. +// Index reads stay under Convex's 4,096: at most ~1,333 page settings +// reads, and the byte charge caps images near 550 at three reads each. +const duplicateMaxBytes = 12 * 1024 * 1024; +const duplicateMaxDocuments = 4000; +// An image's asset copy reads at most 22 asset rows (one active row, up to +// 20 legacy rows, one upload placeholder), each well under 1 KiB. +const duplicateImageReadBytes = 22 * 1024; + +/// Counts a duplicate's reads and writes against its budget, and refuses +/// the duplicate as soon as the budget is spent, before Convex's own +/// limits are. +class DuplicateBudget { + private bytes = duplicateMaxBytes; + private documents = duplicateMaxDocuments; + + spend(cost: { bytes?: number; documents?: number }): void { + this.bytes -= cost.bytes ?? 0; + this.documents -= cost.documents ?? 0; + if (this.bytes < 0 || this.documents < 0) { + throw errorWithCode( + "STRATEGY_TOO_LARGE_TO_DUPLICATE", + "This strategy is too large to duplicate.", + ); + } + } + + /// Reads a query's rows, each charged its stored size. + async read(rows: AsyncIterable): Promise { + const result: T[] = []; + for await (const row of rows) { + this.spend({ bytes: getConvexSize(row), documents: 1 }); + result.push(row); + } + return result; + } +} + +/// Copies a strategy into the caller's library in one transaction: pages, +/// page settings, live elements and lineups under fresh publicIds, and an +/// image asset row per image the copy shows. The rows share the source's +/// stored bytes, so deleting either strategy leaves the other's images alone. +export const duplicate = mutation({ + args: { + ...cloudProtocolArgs, + sourceStrategyPublicId: v.string(), + publicId: v.string(), + name: v.string(), + folderPublicId: v.optional(v.string()), + }, + returns: createResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const source = await getStrategyByPublicId( + ctx, + args.sourceStrategyPublicId, + ); + const { user } = await assertStrategyRole(ctx, source, "editor"); + + const existing = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", args.publicId)) + .first(); + if (existing !== null) { + if (existing.ownerId === user._id) { + // A retry of a duplicate that already committed. + return { ok: true, reused: true } as const; + } + throw conflictError(`Strategy publicId already exists: ${args.publicId}`); + } + + // The copy lands in the folder the caller is browsing when it is theirs. + // Browsing a folder someone shared with them, the copy goes to the root + // of their own library: it is theirs, not the folder owner's. + const folder = + args.folderPublicId === undefined + ? null + : await ctx.db + .query("folders") + .withIndex("by_publicId", (q) => + q.eq("publicId", args.folderPublicId!), + ) + .first(); + const folderId = folder?.ownerId === user._id ? folder._id : undefined; + const now = Date.now(); + const strategyId = await ctx.db.insert("strategies", { + publicId: args.publicId, + ownerId: user._id, + folderId, + name: args.name, + mapData: source.mapData, + revision: 0, + themeProfileId: source.themeProfileId, + themeOverridePalette: source.themeOverridePalette, + createdAt: now, + updatedAt: now, + }); + + // One transaction copies everything. Past the budget the whole + // duplicate is refused with a clear error: the transaction rolls back, + // so no part of a copy is ever left behind. + const budget = new DuplicateBudget(); + const pageIdMap = new Map, Id<"pages">>(); + // Pages in the trash are not copied, nor read. + const sourcePages = await budget.read(livePagesQuery(ctx, source._id)); + for (const page of sourcePages) { + const pageId = await ctx.db.insert("pages", { + publicId: createPublicId(), + strategyId, + name: page.name, + ...(page.isAutoNamed === undefined + ? {} + : { isAutoNamed: page.isAutoNamed }), + sortIndex: page.sortIndex, + isAttack: page.isAttack, + revision: 1, + createdAt: now, + updatedAt: now, + }); + pageIdMap.set(page._id, pageId); + const content = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .first(); + // The settings read, and the two rows this page's copy writes. + budget.spend({ + bytes: content === null ? 0 : getConvexSize(content), + documents: 2, + }); + await ctx.db.insert("pageContents", { + pageId, + settings: content?.settings, + revision: 1, + createdAt: now, + updatedAt: now, + }); + } + + // Each image the copy shows, keyed by its asset id in the copy, mapped to + // its asset id in the source. A placed image's asset id is its element id, + // which the copy renames; lineup images keep their ids, since asset ids + // are scoped to a strategy. + const sourceAssetIdByCopyId = new Map(); + + const showImage = (copyAssetId: string, sourceAssetId: string) => { + if (sourceAssetIdByCopyId.has(copyAssetId)) return; + budget.spend({ bytes: duplicateImageReadBytes }); + sourceAssetIdByCopyId.set(copyAssetId, sourceAssetId); + }; + // Read page by page, so content in the trash, which is not copied, is + // not read either and cannot push the copy over its budget. + const sourceElements: Doc<"elements">[] = []; + const sourceLineups: Doc<"lineups">[] = []; + for (const page of sourcePages) { + sourceElements.push( + ...(await budget.read( + ctx.db + .query("elements") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)), + )), + ); + sourceLineups.push( + ...(await budget.read( + ctx.db + .query("lineups") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)), + )), + ); + } + const copiedElements: ReferencingElement[] = []; + const copiedLineupAgents: Pick< + Doc<"lineupAgents">, + "pageId" | "originId" | "agentType" + >[] = []; + for (const element of sourceElements) { + const pageId = pageIdMap.get(element.pageId); + if (element.deleted || pageId === undefined) continue; + const publicId = createPublicId(); + const sourceAssetId = + element.elementType === "image" + ? collectAssetIdFromElementPayload(element.payload) + : null; + if (sourceAssetId !== null) showImage(publicId, sourceAssetId); + const copiedElement = { + publicId, + strategyId, + pageId, + elementType: element.elementType, + payloadKind: element.payloadKind, + payloadVersion: element.payloadVersion, + payload: { + ...element.payload, + data: { ...element.payload.data, id: publicId }, + }, + sortIndex: element.sortIndex, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }; + budget.spend({ documents: assetIdsOfRow(copiedElement).size }); + const elementId = await ctx.db.insert("elements", copiedElement); + await insertAssetReferences(ctx, { elementId }, copiedElement); + copiedElements.push(copiedElement); + } + + const newLineupId = lineupIdMap(); + for (const lineup of sourceLineups) { + const pageId = pageIdMap.get(lineup.pageId); + if (lineup.deleted || pageId === undefined) continue; + for (const assetId of collectAssetIdsFromLineupPayload(lineup.payload)) { + showImage(assetId, assetId); + } + const copy = copiedLineupRow(lineup.payload, newLineupId); + const copiedLineup = { + publicId: copy.publicId, + strategyId, + pageId, + payloadKind: lineup.payloadKind, + payloadVersion: lineup.payloadVersion, + payload: copy.payload, + sortIndex: lineup.sortIndex, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }; + budget.spend({ documents: assetIdsOfRow(copiedLineup).size }); + const lineupId = await ctx.db.insert("lineups", copiedLineup); + await insertAssetReferences(ctx, { lineupId }, copiedLineup); + for (const agent of lineupAgentsOf(copiedLineup.payload)) { + budget.spend({ documents: 1 }); + await ctx.db.insert("lineupAgents", { + strategyId, + pageId, + lineupId, + ...agent, + }); + copiedLineupAgents.push({ pageId, ...agent }); + } + const items = lineupGroupItems(copiedLineup.payload); + budget.spend({ documents: items.size }); + for (const item of items) { + await ctx.db.insert("lineupItems", { pageId, lineupId, item }); + } + } + + for (const [targetAssetPublicId, sourceAssetPublicId] of + sourceAssetIdByCopyId) { + const copied = await copyActiveAssetToStrategy(ctx, { + sourceStrategyId: source._id, + sourceAssetPublicId, + targetStrategyId: strategyId, + targetAssetPublicId, + userId: user._id, + now, + }); + if (copied === "uploading") { + // Throwing discards the whole copy. A retry once the upload lands + // copies the image instead of leaving the copy without it for good. + throw conflictError( + "An image in this strategy is still uploading. Try again once it finishes.", + ); + } + } + + // The copy's summary comes from the rows just copied: reading them back + // would read the strategy's agents a second time. + await storeStrategyAgentSummary( + ctx, + strategyId, + agentTypesOf(copiedElements, copiedLineupAgents), + ); + return { ok: true } as const; + }, +}); + +export const update = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + expectedRevision: v.number(), + name: v.optional(v.string()), + mapData: v.optional(v.string()), + themeProfileId: v.optional(v.string()), + clearThemeProfileId: v.optional(v.boolean()), + themeOverridePalette: v.optional(mapThemePaletteValidator), + clearThemeOverridePalette: v.optional(v.boolean()), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + + const patch: Record = {}; + + if (args.name !== undefined && args.name !== strategy.name) { + patch.name = args.name; + } + if (args.mapData !== undefined && args.mapData !== strategy.mapData) { + patch.mapData = args.mapData; + } + + if (args.clearThemeProfileId === true) { + if (strategy.themeProfileId !== undefined) { + patch.themeProfileId = undefined; + } + } else if ( + args.themeProfileId !== undefined && + args.themeProfileId !== strategy.themeProfileId + ) { + patch.themeProfileId = args.themeProfileId; + } + + if (args.clearThemeOverridePalette === true) { + if (strategy.themeOverridePalette !== undefined) { + patch.themeOverridePalette = undefined; + } + } else if (args.themeOverridePalette !== undefined) { + patch.themeOverridePalette = args.themeOverridePalette; + } + + if (Object.keys(patch).length === 0) { + return { ok: true, reused: true, revision: strategy.revision } as const; + } + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { + ...patch, + revision, + updatedAt: Date.now(), + }); + return { ok: true, revision } as const; + }, +}); + +export const move = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + expectedRevision: v.number(), + folderPublicId: v.optional(v.string()), + }, + returns: revisionResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "owner"); + + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + let folderId; + if (args.folderPublicId !== undefined) { + const folder = await getFolderByPublicId(ctx, args.folderPublicId); + if (folder.ownerId !== strategy.ownerId) { + throw forbiddenError(); + } + folderId = folder._id; + } + + const revision = strategy.revision + 1; + await ctx.db.patch(strategy._id, { + folderId, + revision, + updatedAt: Date.now(), + }); + + return { ok: true, revision } as const; + }, +}); + +const deleteStrategy = mutation({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + expectedRevision: v.number(), + }, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "owner"); + if (args.expectedRevision !== strategy.revision) { + throw conflictError("Strategy revision mismatch"); + } + + // Pages in the trash are left to their purge (purgeTrashedPages), which + // needs no strategy: one purge scheduled per page must stay within + // Convex's scheduling limit however many pages the trash holds. Nothing + // can reach or restore them once the strategy is gone. + const pages = await livePagesQuery(ctx, strategy._id).collect(); + + for (const page of pages) { + const pageContents = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", page._id)) + .collect(); + for (const pageContent of pageContents) { + await ctx.db.delete(pageContent._id); + } + await ctx.db.delete(page._id); + await ctx.scheduler.runAfter(0, purgeDeletedPageOrphansRef, { + pageId: page._id, + strategyId: strategy._id, + }); + } + + const collaborators = await ctx.db + .query("strategyCollaborators") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const collaborator of collaborators) { + await ctx.db.delete(collaborator._id); + } + + const invites = await ctx.db + .query("inviteTokens") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const invite of invites) { + await ctx.db.delete(invite._id); + } + + const shareLinks = await ctx.db + .query("shareLinks") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + for (const shareLink of shareLinks) { + await ctx.db.delete(shareLink._id); + } + + await ctx.scheduler.runAfter(0, markDeletedStrategyImageAssetsRef, { + strategyId: strategy._id, + }); + await deleteStrategyAgentSummary(ctx, strategy._id); + await ctx.db.delete(strategy._id); + return { ok: true } as const; + }, +}); + +export { deleteStrategy as delete }; diff --git a/convex/strategy.ts b/convex/strategy.ts new file mode 100644 index 00000000..a07e18d2 --- /dev/null +++ b/convex/strategy.ts @@ -0,0 +1,169 @@ +import { query, type QueryCtx } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; +import { assertStrategyReadable } from "./lib/auth"; +import { clientUpgradeRequiredError } from "./lib/errors"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { + elementsOnPages, + hasTrashedPages, + getStrategyByPublicId, + lineupsOnPages, + listLivePages, + sortByNumberField, +} from "./lib/entities"; +import { + collectReferencedAssetIds, + getViewerAssetForStrategy, + serializeAssetForViewer, +} from "./lib/imageAssets"; +import { + serializeElement, + serializeLineup, + serializePageContent, + serializePageDescriptor, + serializeStrategyHeader, +} from "./lib/snapshotSerialization"; +import { internalError } from "./lib/errors"; +import { + fullStrategySnapshotValidator, + strategyShellValidator, +} from "./lib/publicValidators"; + +async function getPageContent( + ctx: QueryCtx, + pageId: Id<"pages">, +): Promise> { + const rows = await ctx.db + .query("pageContents") + .withIndex("by_pageId", (q) => q.eq("pageId", pageId)) + .take(2); + if (rows.length !== 1) { + throw internalError("Each page must have exactly one page content row."); + } + return rows[0]!; +} + +export const getShell = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + // A share link the reader holds (see assertStrategyReadable): lets + // someone without an account view a strategy shared with them. + shareToken: v.optional(v.string()), + }, + returns: strategyShellValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const role = await assertStrategyReadable(ctx, strategy, args.shareToken); + const pages = await listLivePages(ctx, strategy._id); + + return { + header: serializeStrategyHeader(strategy, role), + pages: sortByNumberField(pages, "sortIndex").map((page) => + serializePageDescriptor(strategy.publicId, page), + ), + }; + }, +}); + +export const getFullSnapshot = query({ + args: { + ...cloudProtocolArgs, + strategyPublicId: v.string(), + shareToken: v.optional(v.string()), + // Set by clients that ask images:listReferencedAssetIds, not this + // snapshot, whether an upload is still wanted: a snapshot that leaves + // the trash's pages out cannot make them drop one. Older clients are + // refused while the strategy holds trashed pages (see below). + acceptsTrashedPagesLeftOut: v.optional(v.boolean()), + }, + returns: fullStrategySnapshotValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + const role = await assertStrategyReadable(ctx, strategy, args.shareToken); + // An older client decides from this snapshot whether an upload is still + // wanted. Without the trash's pages it would drop the bytes of an image + // on a page that can be restored, so it is told to upgrade instead; its + // upload check keeps the bytes when the read fails. A reload of the web + // app is the upgrade. + if ( + args.acceptsTrashedPagesLeftOut !== true && + (await hasTrashedPages(ctx, strategy._id)) + ) { + throw clientUpgradeRequiredError(); + } + // Content on a page in the trash is left out with its page. + const pages = await listLivePages(ctx, strategy._id); + const [elements, lineups] = await Promise.all([ + elementsOnPages(ctx, pages), + lineupsOnPages(ctx, pages), + ]); + const orderedPages = sortByNumberField(pages, "sortIndex"); + const pagePublicIds = new Map( + orderedPages.map((page) => [page._id, page.publicId]), + ); + const pageContents = await Promise.all( + orderedPages.map((page) => getPageContent(ctx, page._id)), + ); + const visibleElements = elements.filter((element) => + pagePublicIds.has(element.pageId), + ); + const visibleLineups = lineups.filter((lineup) => + pagePublicIds.has(lineup.pageId), + ); + const referencedAssetIds = collectReferencedAssetIds( + visibleElements, + visibleLineups, + ); + const assets = await Promise.all( + [...referencedAssetIds].map((assetPublicId) => + getViewerAssetForStrategy(ctx, strategy._id, assetPublicId), + ), + ); + + return { + header: serializeStrategyHeader(strategy, role), + pages: orderedPages.map((page, index) => { + const content = serializePageContent(pageContents[index]!); + return { + ...serializePageDescriptor(strategy.publicId, page), + settings: content.settings, + contentRevision: content.revision, + contentCreatedAt: content.createdAt, + contentUpdatedAt: content.updatedAt, + }; + }), + elements: visibleElements + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((element) => + serializeElement( + strategy.publicId, + pagePublicIds.get(element.pageId)!, + element, + ), + ), + lineups: visibleLineups + .sort((left, right) => left.sortIndex - right.sortIndex) + .map((lineup) => + serializeLineup( + strategy.publicId, + pagePublicIds.get(lineup.pageId)!, + lineup, + ), + ), + assets: ( + await Promise.all( + assets + .filter((asset): asset is Doc<"imageAssets"> => asset !== null) + .map((asset) => serializeAssetForViewer(ctx, asset)), + ) + ).sort((left, right) => left.publicId.localeCompare(right.publicId)), + }; + }, +}); diff --git a/convex/strategyDuplicate.test.ts b/convex/strategyDuplicate.test.ts new file mode 100644 index 00000000..7f868d18 --- /dev/null +++ b/convex/strategyDuplicate.test.ts @@ -0,0 +1,1454 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { lineupsPayload, oneLineupPayload } from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createFolder = makeFunctionReference<"mutation">("folders:create"); +const createStrategy = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const duplicateStrategy = makeFunctionReference<"mutation">( + "strategies:duplicate", +); +const deleteStrategy = makeFunctionReference<"mutation">("strategies:delete"); +const listStrategies = makeFunctionReference<"query">( + "strategies:listForFolder", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const deletePage = makeFunctionReference<"mutation">("pages:delete"); +const purgeOldTombstones = makeFunctionReference<"mutation">( + "maintenance:purgeOldTombstones", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const listImages = makeFunctionReference<"query">("images:listForStrategy"); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; +const source = "duplicate-source"; +const firstPage = "duplicate-source-page-1"; +const secondPage = "duplicate-source-page-2"; +const settings = { agentSize: 40, abilitySize: 30, useNeutralTeamColors: true }; + +function identity(subject: string) { + return { + issuer: "https://duplicate.test", + subject, + tokenIdentifier: `duplicate|${subject}`, + name: `User ${subject}`, + }; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; + other: Harness; +}> { + const t = convexTest(schema, modules); + // A deployment whose reference backfill has run, as every one will be. + await t.run(markAssetReferencesReady); + const owner = t.withIdentity(identity("owner")); + const other = t.withIdentity(identity("other")); + await owner.mutation(ensureCurrentUser, protocol); + await other.mutation(ensureCurrentUser, protocol); + return { t, owner, other }; +} + +// One lineup group holding one lineup. The group, its lineup and its +// landing share one id, as a landing made with its lineup does. +const seedLineupData = { + id: "item-1", + origins: [ + { id: "origin-1", agent: { type: "sova", lineUpID: "origin-1" } }, + ], + landings: [ + { id: "item-1", ability: { type: "shock_dart", lineUpID: "item-1" } }, + ], + links: [ + { + id: "item-1", + originId: "origin-1", + landingId: "item-1", + name: "Shock dart", + notes: "Two bounces", + images: [{ id: "lineup-image" }], + }, + ], +}; + +/// A two-page strategy with a placed image, a lineup with an image, an agent, +/// and one deleted element, backed by active R2 asset rows. +async function seedSource(t: RootHarness, owner: Harness): Promise { + await owner.mutation(createStrategy, { + ...protocol, + publicId: source, + name: "Split A execute", + mapData: "split", + initialPagePublicId: firstPage, + initialPageName: "Setup", + initialPageIsAutoNamed: false, + initialPageIsAttack: true, + initialPageSettings: settings, + themeProfileId: "theme-1", + }); + await owner.mutation(addPage, { + ...protocol, + strategyPublicId: source, + pagePublicId: secondPage, + name: "Retake", + sortIndex: 1, + isAttack: false, + expectedRevision: 0, + }); + // Uploads land before the content that shows them, as on a device that + // placed the images online. + await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", source)) + .unique(); + const now = Date.now(); + for (const publicId of ["placed-image", "lineup-image"]) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy!._id, + uploadAttemptPublicId: `${publicId}-attempt`, + objectKey: `strategies/${source}/${publicId}.png`, + uploadStatus: "active", + fileExtension: ".png", + mimeType: "image/png", + width: 64, + height: 32, + byteSize: 100, + uploadedAt: now, + createdAt: now, + updatedAt: now, + }); + } + }); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "seed", + ops: [ + { + opId: "add-image", + type: "element.add", + elementPublicId: "placed-image", + pagePublicId: firstPage, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: "placed-image", elementType: "image", scale: 2 }, + }, + sortIndex: 3, + }, + { + opId: "add-agent", + type: "element.add", + elementPublicId: "placed-agent", + pagePublicId: secondPage, + payload: { + kind: "agent", + payloadVersion: 1, + data: { id: "placed-agent", type: "jett" }, + }, + sortIndex: 1, + }, + { + opId: "add-removed", + type: "element.add", + elementPublicId: "removed-text", + pagePublicId: firstPage, + payload: { + kind: "text", + payloadVersion: 1, + data: { id: "removed-text", text: "gone" }, + }, + sortIndex: 4, + }, + { + opId: "remove-text", + type: "element.delete", + elementPublicId: "removed-text", + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + // One lineup group showing an image. + { + opId: "add-lineup", + type: "lineup.add", + lineupPublicId: "item-1", + pagePublicId: secondPage, + payload: { + kind: "lineups", + payloadVersion: 1, + data: seedLineupData, + }, + sortIndex: 0, + }, + ], + }); +} + +async function duplicate( + user: Harness, + publicId = "duplicate-copy", + extra: { folderPublicId?: string; sourceStrategyPublicId?: string } = {}, +) { + return await user.mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: source, + publicId, + name: "Split A execute (Copy)", + ...extra, + }); +} + +type ImageRow = { publicId: string; uploadStatus: string; url: string | null }; + +async function imageUrls(user: Harness, strategyPublicId: string) { + const images = (await user.query(listImages, { + strategyPublicId, + })) as ImageRow[]; + return Object.fromEntries(images.map((image) => [image.publicId, image.url])); +} + +function mockR2Deletes() { + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(null, { status: 204 }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function deletedKeys(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls + .map((call) => new URL(String(call[0])).pathname) + .sort(); +} + +async function deleteAndSweep( + t: RootHarness, + owner: Harness, + strategyPublicId: string, +) { + const revision = await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .unique(); + return strategy!.revision; + }); + await owner.mutation(deleteStrategy, { + ...protocol, + strategyPublicId, + expectedRevision: revision, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); +} + +beforeAll(() => { + process.env.R2_ACCOUNT_ID = "duplicate-account"; + process.env.R2_BUCKET = "duplicate-bucket"; + process.env.R2_ACCESS_KEY_ID = "duplicate-access-key"; + process.env.R2_SECRET_ACCESS_KEY = "duplicate-secret"; + process.env.R2_PUBLIC_BASE_URL = "https://media.duplicate.test"; + process.env.R2_S3_ENDPOINT = "https://duplicate.r2.test"; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("strategies:duplicate", () => { + test("copies pages, live content, and images under fresh ids", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + + await expect(duplicate(owner)).resolves.toEqual({ ok: true }); + + type Row = Record; + const copy = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId: "duplicate-copy", + })) as { header: Row; pages: Row[]; elements: Row[]; lineups: Row[] }; + expect(copy.header).toMatchObject({ + name: "Split A execute (Copy)", + mapData: "split", + themeProfileId: "theme-1", + role: "owner", + }); + const pages = [...copy.pages].sort((a, b) => a.sortIndex - b.sortIndex); + expect(pages).toMatchObject([ + { + name: "Setup", + isAutoNamed: false, + isAttack: true, + sortIndex: 0, + settings, + }, + { name: "Retake", isAttack: false, sortIndex: 1 }, + ]); + const [copyFirstPage, copySecondPage] = pages.map((page) => page.publicId); + expect([firstPage, secondPage]).not.toContain(copyFirstPage); + expect([firstPage, secondPage]).not.toContain(copySecondPage); + + // The deleted text element is not copied. + expect(copy.elements).toHaveLength(2); + const image = copy.elements.find((row) => row.elementType === "image")!; + expect(image).toMatchObject({ pagePublicId: copyFirstPage, sortIndex: 3 }); + expect(image.publicId).not.toBe("placed-image"); + expect(image.payload.data).toEqual({ + id: image.publicId, + elementType: "image", + scale: 2, + }); + + const agent = copy.elements.find((row) => row.elementType === "agent")!; + expect(agent.pagePublicId).toBe(copySecondPage); + expect(agent.publicId).not.toBe("placed-agent"); + expect(agent.payload.data).toEqual({ id: agent.publicId, type: "jett" }); + + expect(copy.lineups).toHaveLength(1); + const lineup = copy.lineups[0]!; + expect(lineup.pagePublicId).toBe(copySecondPage); + expect(lineup.payload.kind).toBe("lineups"); + // Fresh ids, still shared where the source shared them (the group, its + // lineup and its landing keep one id), with each link naming its ends' + // new ids and each marker following its spot. + const { id, origins } = lineup.payload.data; + const originId = origins[0].id; + expect(lineup.publicId).toBe(id); + expect(id).not.toBe("item-1"); + expect(originId).not.toBe("origin-1"); + expect(originId).not.toBe(id); + expect(lineup.payload.data).toEqual({ + id, + origins: [{ id: originId, agent: { type: "sova", lineUpID: originId } }], + landings: [{ id, ability: { type: "shock_dart", lineUpID: id } }], + links: [ + { + id, + originId, + landingId: id, + name: "Shock dart", + notes: "Two bounces", + images: [{ id: "lineup-image" }], + }, + ], + }); + + expect(await imageUrls(owner, "duplicate-copy")).toEqual({ + [image.publicId]: + `https://media.duplicate.test/strategies/${source}/placed-image.png`, + "lineup-image": + `https://media.duplicate.test/strategies/${source}/lineup-image.png`, + }); + + const summaries = (await owner.query(listStrategies, {})) as Array<{ + publicId: string; + }>; + expect(summaries.map((entry) => entry.publicId).sort()).toEqual([ + "duplicate-copy", + source, + ]); + + // Copies are new references to the same bytes, never new upload intents. + const copiedRows = await t.run(async (ctx) => { + const copyRow = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "duplicate-copy")) + .unique(); + return await ctx.db + .query("imageAssets") + .withIndex("by_strategyId", (q) => q.eq("strategyId", copyRow!._id)) + .collect(); + }); + expect(copiedRows).toHaveLength(2); + for (const row of copiedRows) { + expect(row.uploadAttemptPublicId).toBeUndefined(); + expect(row).toMatchObject({ + provider: "r2", + uploadStatus: "active", + mimeType: "image/png", + width: 64, + height: 32, + }); + } + }); + + test("deleting the original keeps the copy's images, then deleting the copy frees them", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await duplicate(owner); + const urlsBefore = await imageUrls(owner, "duplicate-copy"); + + await deleteAndSweep(t, owner, source); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(await imageUrls(owner, "duplicate-copy")).toEqual(urlsBefore); + + await deleteAndSweep(t, owner, "duplicate-copy"); + + expect(deletedKeys(fetchMock)).toEqual([ + `/duplicate-bucket/strategies/${source}/lineup-image.png`, + `/duplicate-bucket/strategies/${source}/placed-image.png`, + ]); + expect( + await t.run(async (ctx) => await ctx.db.query("imageAssets").collect()), + ).toEqual([]); + }); + + test("a lineup group copies whole under new ids, with its shared spots, details and images, and outlives the original", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await t.run(async (ctx) => { + const strategy = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", source)) + .unique(); + const now = Date.now(); + for (const publicId of ["link-image", "fan-out-image"]) { + await ctx.db.insert("imageAssets", { + publicId, + provider: "r2", + strategyId: strategy!._id, + uploadAttemptPublicId: `${publicId}-attempt`, + objectKey: `strategies/${source}/${publicId}.png`, + uploadStatus: "active", + fileExtension: ".png", + mimeType: "image/png", + width: 64, + height: 32, + byteSize: 100, + uploadedAt: now, + createdAt: now, + updatedAt: now, + }); + } + }); + // Two lineups from different origins into one landing (fan-in), and a + // third from the first one's origin to another landing (fan-out): one + // group. The shared landing takes the first lineup's id, as a landing + // made with its lineup does, and the group takes it too. A second group + // on the page holds one lineup. + const group = lineupsPayload("link-a", { + origins: [ + { id: "origin-a", position: { dx: 10, dy: 10 } }, + { id: "origin-b", agentType: "brimstone", position: { dx: 20, dy: 20 } }, + ], + landings: [ + { id: "link-a", position: { dx: 300, dy: 300 } }, + { id: "landing-c", position: { dx: 500, dy: 500 } }, + ], + links: [ + { + id: "link-a", + originId: "origin-a", + landingId: "link-a", + name: "From heaven", + notes: "Run and throw", + images: [{ id: "link-image", fileExtension: ".png" }], + }, + { + id: "link-b", + originId: "origin-b", + landingId: "link-a", + name: "From mid", + youtubeLink: "https://youtu.be/b", + }, + { + id: "link-c", + originId: "origin-a", + landingId: "landing-c", + name: "Fan out", + images: [{ id: "fan-out-image", fileExtension: ".png" }], + }, + ], + }); + const lone = oneLineupPayload("lone", { name: "Lone" }); + const added = (await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "shared-spots", + ops: [group, lone].map((payload, index) => ({ + opId: `add-${payload.data.id}`, + type: "lineup.add", + lineupPublicId: payload.data.id, + pagePublicId: firstPage, + payload, + sortIndex: 10 + index, + })), + })) as { results: Array<{ status: string }> }; + expect(added.results.map((result) => result.status)).toEqual([ + "applied", + "applied", + ]); + + await duplicate(owner); + await deleteAndSweep(t, owner, source); + + type Row = { + publicId: string; + pagePublicId: string; + sortIndex: number; + payload: { + kind: string; + payloadVersion: number; + data: Record; + }; + }; + const copy = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId: "duplicate-copy", + })) as { + pages: Array<{ publicId: string; sortIndex: number }>; + lineups: Row[]; + }; + // These sit on the first page (the seed's group is on the second). + const copyFirstPage = [...copy.pages].sort( + (a, b) => a.sortIndex - b.sortIndex, + )[0]!.publicId; + const copied = copy.lineups + .filter((row) => row.pagePublicId === copyFirstPage) + .sort((a, b) => a.sortIndex - b.sortIndex); + expect(copied).toHaveLength(2); + const [copiedGroup, copiedLone] = copied as [Row, Row]; + + // Read the new ids off the copy, in the source's order. + const newIdOf = new Map(); + const remember = (sourceId: string, copyId: string) => { + // One source id always becomes one copy id. + expect(newIdOf.get(sourceId) ?? copyId).toBe(copyId); + newIdOf.set(sourceId, copyId); + }; + for (const [sourcePayload, row] of [ + [group, copiedGroup], + [lone, copiedLone], + ] as const) { + const data = row.payload.data; + expect(row.publicId).toBe(data.id); + remember(sourcePayload.data.id, data.id); + for (const field of ["origins", "landings", "links"] as const) { + expect(data[field]).toHaveLength(sourcePayload.data[field].length); + sourcePayload.data[field].forEach((entry, index) => + remember(entry.id, data[field][index].id), + ); + } + } + // Ids that were apart stay apart, and none is reused from the source. + const sourceIds = [...newIdOf.keys()].sort(); + const copyIds = [...newIdOf.values()]; + expect(sourceIds).toEqual([ + "landing-c", + "link-a", + "link-b", + "link-c", + "lone", + "lone-landing", + "lone-origin", + "origin-a", + "origin-b", + ]); + expect(new Set(copyIds).size).toBe(copyIds.length); + for (const copyId of copyIds) expect(sourceIds).not.toContain(copyId); + + // Each copy is its source row under the new ids: each link naming its + // ends' new ids, each marker following its spot, every place, detail + // and image as it was. + const renamed = (payload: typeof group) => { + const id = (old: string) => newIdOf.get(old)!; + return { + ...payload, + data: { + id: id(payload.data.id), + origins: payload.data.origins.map((origin) => ({ + ...origin, + id: id(origin.id), + agent: { ...origin.agent, lineUpID: id(origin.id) }, + })), + landings: payload.data.landings.map((landing) => ({ + ...landing, + id: id(landing.id), + ability: { ...landing.ability, lineUpID: id(landing.id) }, + })), + links: payload.data.links.map((link) => ({ + ...link, + id: id(link.id), + originId: id(link.originId), + landingId: id(link.landingId), + })), + }, + }; + }; + expect(copiedGroup.payload).toEqual(renamed(group)); + expect(copiedLone.payload).toEqual(renamed(lone)); + // Fan-in and fan-out survive: both lineups into the shared landing name + // one new id, still the group's and the first lineup's, and both + // lineups from the shared origin name one new id. + const links = copiedGroup.payload.data.links; + expect(links[1].landingId).toBe(links[0].landingId); + expect(links[0].landingId).toBe(copiedGroup.publicId); + expect(links[0].id).toBe(copiedGroup.publicId); + expect(links[2].originId).toBe(links[0].originId); + + // The copy's lineup images are its own references, one per image across + // every link, and survived the original. + expect(fetchMock).not.toHaveBeenCalled(); + const urls = await imageUrls(owner, "duplicate-copy"); + expect(urls["link-image"]).toEqual(expect.stringContaining("link-image.png")); + expect(urls["fan-out-image"]).toEqual( + expect.stringContaining("fan-out-image.png"), + ); + const lineupReferences = await t.run(async (ctx) => { + const copyRow = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "duplicate-copy")) + .unique(); + return (await ctx.db.query("assetReferences").collect()) + .filter( + (reference) => + reference.strategyId === copyRow!._id && + reference.lineupId !== undefined, + ) + .map((reference) => reference.assetPublicId) + .sort(); + }); + expect(lineupReferences).toEqual([ + "fan-out-image", + "lineup-image", + "link-image", + ]); + + await deleteAndSweep(t, owner, "duplicate-copy"); + expect(deletedKeys(fetchMock)).toEqual( + expect.arrayContaining([ + `/duplicate-bucket/strategies/${source}/link-image.png`, + `/duplicate-bucket/strategies/${source}/fan-out-image.png`, + ]), + ); + }); + + test("an image whose tombstone is purged is reclaimed once nothing shows it", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await duplicate(owner); + + // The copy's placed image (its own asset row, sharing the source's bytes). + type Row = Record; + const copy = (await owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId: "duplicate-copy", + })) as { pages: Row[]; elements: Row[] }; + const copyImage = copy.elements.find((row) => row.elementType === "image")!; + const copyImagePage = copyImage.pagePublicId as string; + + // Delete the copy's image element, leaving a tombstone. + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: "duplicate-copy", + clientId: "copy-editor", + ops: [ + { + opId: "delete-copy-image", + type: "element.delete", + elementPublicId: copyImage.publicId, + pagePublicId: copyImagePage, + expectedElementRevision: 1, + }, + ], + }); + // 31 days later the tombstone is purged. + vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000); + await t.mutation(purgeOldTombstones, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + // Then the copy's page and the original go. + const copyRevision = await t.run(async (ctx) => { + const row = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", "duplicate-copy")) + .unique(); + return row!.revision; + }); + await owner.mutation(deletePage, { + ...protocol, + strategyPublicId: "duplicate-copy", + pagePublicId: copyImagePage, + expectedRevision: copyRevision, + }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + await deleteAndSweep(t, owner, source); + + // The copy keeps no live row for the image, and its bytes are deleted + // once no row points at them. + const copyRows = await t.run(async (ctx) => + (await ctx.db.query("imageAssets").collect()).filter( + (row) => + row.publicId === copyImage.publicId && row.uploadStatus !== "deleted", + ), + ); + expect(copyRows).toEqual([]); + expect(deletedKeys(fetchMock)).toContain( + `/duplicate-bucket/strategies/${source}/placed-image.png`, + ); + }); + + test("a purged tombstone keeps an image a recent tombstone may still restore", async () => { + vi.useFakeTimers(); + mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + const day = 24 * 60 * 60 * 1000; + const activeRows = async () => + (await t.run(async (ctx) => await ctx.db.query("imageAssets").collect())) + .filter((row) => row.publicId === "placed-image") + .map((row) => row.uploadStatus); + + // The placed image is deleted; a month later a lineup shows the same + // image and is deleted too, so undo could still restore it. + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "editor", + ops: [ + { + opId: "delete-placed", + type: "element.delete", + elementPublicId: "placed-image", + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + ], + }); + vi.setSystemTime(Date.now() + 31 * day); + const showing = oneLineupPayload("late-link", { + images: [{ id: "placed-image" }], + }); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "editor", + ops: [ + { + opId: "add-late-link", + type: "lineup.add", + lineupPublicId: "late-link", + pagePublicId: firstPage, + payload: showing, + sortIndex: 9, + }, + { + opId: "delete-late-link", + type: "lineup.delete", + lineupPublicId: "late-link", + pagePublicId: firstPage, + expectedLineupRevision: 1, + }, + ], + }); + + // Purging the old element tombstone keeps the image: the lineup's + // tombstone is still inside its retention window. + await t.mutation(purgeOldTombstones, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await activeRows()).toEqual(["active"]); + + // Once that tombstone is purged too, the image is reclaimed. + vi.setSystemTime(Date.now() + 31 * day); + await t.mutation(purgeOldTombstones, {}); + await t.finishAllScheduledFunctions(vi.runAllTimers); + expect(await activeRows()).not.toContain("active"); + }); + + test("deleting the copy leaves the original's images alone", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + const urlsBefore = await imageUrls(owner, source); + await duplicate(owner); + + await deleteAndSweep(t, owner, "duplicate-copy"); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(await imageUrls(owner, source)).toEqual(urlsBefore); + }); + + test("a copy of a copy is one more reference to the same bytes", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await duplicate(owner, "copy-1"); + await owner.mutation(duplicateStrategy, { + ...protocol, + sourceStrategyPublicId: "copy-1", + publicId: "copy-2", + name: "copy of copy", + }); + + await deleteAndSweep(t, owner, source); + await deleteAndSweep(t, owner, "copy-1"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(Object.values(await imageUrls(owner, "copy-2"))).toHaveLength(2); + + await deleteAndSweep(t, owner, "copy-2"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("a legacy Convex-storage image survives the original's deletion", async () => { + vi.useFakeTimers(); + mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + const storageId = await t.run(async (ctx) => { + const id = await ctx.storage.store(new Blob(["legacy"])); + const rows = await ctx.db + .query("imageAssets") + .withIndex("by_publicId", (q) => q.eq("publicId", "placed-image")) + .collect(); + await ctx.db.patch(rows[0]!._id, { + provider: "convex", + objectKey: undefined, + storageId: id, + }); + return id; + }); + await duplicate(owner); + + await deleteAndSweep(t, owner, source); + + await expect( + t.run(async (ctx) => (await ctx.storage.get(storageId)) !== null), + ).resolves.toBe(true); + const copyUrls = await imageUrls(owner, "duplicate-copy"); + expect(Object.keys(copyUrls)).toHaveLength(2); + expect(Object.values(copyUrls).every((url) => url !== null)).toBe(true); + + await deleteAndSweep(t, owner, "duplicate-copy"); + await expect( + t.run(async (ctx) => (await ctx.storage.get(storageId)) === null), + ).resolves.toBe(true); + }); + + test("images the source cannot show are not copied", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await t.run(async (ctx) => { + const rows = await ctx.db.query("imageAssets").collect(); + const placed = rows.find((row) => row.publicId === "placed-image")!; + const lineup = rows.find((row) => row.publicId === "lineup-image")!; + await ctx.db.patch(placed._id, { uploadStatus: "failed" }); + await ctx.db.patch(lineup._id, { uploadStatus: "deleted" }); + }); + + await duplicate(owner); + + expect(await imageUrls(owner, "duplicate-copy")).toEqual({}); + }); + + test("an image still uploading refuses the duplicate until it lands", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + const setPlacedStatus = async (uploadStatus: "pending" | "active") => + await t.run(async (ctx) => { + const [placed] = await ctx.db + .query("imageAssets") + .withIndex("by_publicId", (q) => q.eq("publicId", "placed-image")) + .collect(); + await ctx.db.patch(placed!._id, { uploadStatus }); + }); + await setPlacedStatus("pending"); + + await expect(duplicate(owner)).rejects.toThrow("still uploading"); + const strategies = await t.run( + async (ctx) => await ctx.db.query("strategies").collect(), + ); + expect(strategies.map((row) => row.publicId)).toEqual([source]); + + await setPlacedStatus("active"); + await expect(duplicate(owner)).resolves.toEqual({ ok: true }); + expect(Object.keys(await imageUrls(owner, "duplicate-copy"))).toHaveLength( + 2, + ); + }); + + test("a retried duplicate reuses the copy it already made", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + + await expect(duplicate(owner)).resolves.toEqual({ ok: true }); + await expect(duplicate(owner)).resolves.toEqual({ ok: true, reused: true }); + + const counts = await t.run(async (ctx) => ({ + strategies: (await ctx.db.query("strategies").collect()).length, + pages: (await ctx.db.query("pages").collect()).length, + assets: (await ctx.db.query("imageAssets").collect()).length, + })); + expect(counts).toEqual({ strategies: 2, pages: 4, assets: 4 }); + }); + + test("lands in the caller's folder, or their library root from someone else's", async () => { + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await owner.mutation(createFolder, { + ...protocol, + publicId: "owner-folder", + name: "Executes", + }); + + await duplicate(owner, "in-folder", { folderPublicId: "owner-folder" }); + const inFolder = (await owner.query(listStrategies, { + folderPublicId: "owner-folder", + })) as Array<{ publicId: string }>; + expect(inFolder.map((entry) => entry.publicId)).toEqual(["in-folder"]); + + await other.mutation(createFolder, { + ...protocol, + publicId: "other-folder", + name: "Not yours", + }); + // Duplicating while browsing a folder someone shared with you. + await expect( + duplicate(owner, "from-shared-folder", { + folderPublicId: "other-folder", + }), + ).resolves.toEqual({ ok: true }); + const atRoot = (await owner.query(listStrategies, {})) as Array<{ + publicId: string; + }>; + expect(atRoot.map((entry) => entry.publicId)).toContain( + "from-shared-folder", + ); + await expect( + other.query(listStrategies, { folderPublicId: "other-folder" }), + ).resolves.toEqual([]); + }); +}); + +describe("images placed before their upload", () => { + const createUploadIntent = makeFunctionReference<"mutation">( + "images:createR2UploadIntent", + ); + const markUploadActive = makeFunctionReference<"mutation">( + "images:markR2UploadActive", + ); + const markStaleUploads = makeFunctionReference<"mutation">( + "images:markStaleImageUploadsDeleted", + ); + + async function placeImage(owner: Harness, elementPublicId: string) { + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: `place-${elementPublicId}`, + type: "element.add", + elementPublicId, + pagePublicId: firstPage, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: elementPublicId, elementType: "image" }, + }, + sortIndex: 9, + }, + ], + }); + } + + async function rowsFor(t: RootHarness, publicId: string) { + return await t.run( + async (ctx) => + await ctx.db + .query("imageAssets") + .withIndex("by_publicId", (q) => q.eq("publicId", publicId)) + .collect(), + ); + } + + async function statusFor(user: Harness, publicId: string) { + const images = (await user.query(listImages, { + strategyPublicId: source, + })) as ImageRow[]; + return images.find((image) => image.publicId === publicId) ?? null; + } + + async function upload(owner: Harness, publicId: string) { + const intent = (await owner.mutation(createUploadIntent, { + strategyPublicId: source, + assetPublicId: publicId, + objectKey: `strategies/${source}/${publicId}.png`, + uploadAttemptPublicId: `${publicId}-attempt`, + mimeType: "image/png", + fileExtension: ".png", + })) as { uploadId: string }; + return intent.uploadId; + } + + test("a duplicate waits for an image whose upload has not started, then copies it", async () => { + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: source, + token: "late-editor", + role: "editor", + }); + await other.mutation(redeemShare, { ...protocol, token: "late-editor" }); + + await placeImage(owner, "late-image"); + + // Another device sees an image on its way, not a missing one. + expect(await statusFor(other, "late-image")).toMatchObject({ + uploadStatus: "pending", + url: null, + }); + await expect(duplicate(other, "too-early")).rejects.toThrow( + "still uploading", + ); + + const uploadId = await upload(owner, "late-image"); + const rows = await rowsFor(t, "late-image"); + expect(rows).toHaveLength(1); + expect(rows[0]!._id).toBe(uploadId); + await owner.mutation(markUploadActive, { + strategyPublicId: source, + assetPublicId: "late-image", + uploadId, + byteSize: 100, + mimeType: "image/png", + fileExtension: ".png", + }); + + expect((await statusFor(other, "late-image"))?.url).toBe( + `https://media.duplicate.test/strategies/${source}/late-image.png`, + ); + await expect(duplicate(other, "after-upload")).resolves.toEqual({ + ok: true, + }); + const copyUrls = await imageUrls(other, "after-upload"); + expect(Object.values(copyUrls)).toContain( + `https://media.duplicate.test/strategies/${source}/late-image.png`, + ); + }); + + test("an upload that never comes is swept, and the image reads as unavailable", async () => { + vi.useFakeTimers(); + mockR2Deletes(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "never-uploaded"); + expect(await statusFor(owner, "never-uploaded")).toMatchObject({ + uploadStatus: "pending", + }); + + await owner.mutation(markStaleUploads, { staleBefore: Date.now() + 1 }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + + expect(await rowsFor(t, "never-uploaded")).toEqual([]); + expect(await statusFor(owner, "never-uploaded")).toBeNull(); + + // Moving the image does not bring the spinner back. + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "move-never-uploaded", + type: "element.reorder", + elementPublicId: "never-uploaded", + pagePublicId: firstPage, + sortIndex: 10, + expectedElementRevision: 1, + }, + ], + }); + expect(await rowsFor(t, "never-uploaded")).toEqual([]); + + // With nothing on its way, the copy goes ahead without it. + await expect(duplicate(owner)).resolves.toEqual({ ok: true }); + }); + + test("deleting the image clears its placeholder", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "removed-image"); + expect(await rowsFor(t, "removed-image")).toHaveLength(1); + + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "remove-removed-image", + type: "element.delete", + elementPublicId: "removed-image", + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + ], + }); + + expect(await rowsFor(t, "removed-image")).toEqual([]); + }); + + async function deleteImage(owner: Harness, elementPublicId: string) { + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: `delete-${elementPublicId}`, + type: "element.delete", + elementPublicId, + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + ], + }); + } + + async function restoreImage(owner: Harness, elementPublicId: string) { + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: `restore-${elementPublicId}`, + type: "element.add", + elementPublicId, + pagePublicId: firstPage, + payload: { + kind: "image", + payloadVersion: 1, + data: { id: elementPublicId, elementType: "image" }, + }, + sortIndex: 9, + expectedElementRevision: 2, + }, + ], + }); + } + + test("undoing a fresh delete expects the image again", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "undone-image"); + await deleteImage(owner, "undone-image"); + expect(await rowsFor(t, "undone-image")).toEqual([]); + + // Its upload may still be coming from the device that placed it. + await restoreImage(owner, "undone-image"); + + expect(await statusFor(owner, "undone-image")).toMatchObject({ + uploadStatus: "pending", + }); + }); + + test("undoing the delete of an image placed over a day ago adds no placeholder", async () => { + vi.useFakeTimers(); + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "old-image"); + await deleteImage(owner, "old-image"); + + vi.setSystemTime(Date.now() + 25 * 60 * 60 * 1000); + await restoreImage(owner, "old-image"); + + // Any upload would have landed or been swept by now; it reads as + // unavailable at once instead of spinning for another day. + expect(await rowsFor(t, "old-image")).toEqual([]); + }); + + test("undoing a delete keeps using an image that was uploaded", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + + // placed-image is uploaded (seeded active); deleting leaves its row. + await deleteImage(owner, "placed-image"); + await restoreImage(owner, "placed-image"); + + const rows = await rowsFor(t, "placed-image"); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ uploadStatus: "active" }); + }); + + test("deleting an image keeps the placeholder while a lineup still shows it", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "link-late-image"); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "link-shows-shared", + type: "lineup.add", + lineupPublicId: "link-shows-shared", + pagePublicId: firstPage, + payload: oneLineupPayload("link-shows-shared", { + images: [{ id: "link-late-image", fileExtension: ".png" }], + }), + sortIndex: 2, + }, + ], + }); + expect(await rowsFor(t, "link-late-image")).toHaveLength(1); + + await deleteImage(owner, "link-late-image"); + + expect(await statusFor(owner, "link-late-image")).toMatchObject({ + uploadStatus: "pending", + }); + await expect(duplicate(owner)).rejects.toThrow("still uploading"); + }); + + test("a lineup added later in the same batch keeps a deleted image's placeholder", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "pending-a"); + await placeImage(owner, "pending-b"); + + // One batch: delete A, add a lineup that shows B, then delete placed B. + const response = (await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "delete-a", + type: "element.delete", + elementPublicId: "pending-a", + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + { + opId: "link-shows-b", + type: "lineup.add", + lineupPublicId: "shows-b", + pagePublicId: firstPage, + payload: oneLineupPayload("shows-b", { + images: [{ id: "pending-b", fileExtension: ".png" }], + }), + sortIndex: 5, + }, + { + opId: "delete-b", + type: "element.delete", + elementPublicId: "pending-b", + pagePublicId: firstPage, + expectedElementRevision: 1, + }, + ], + })) as { results: Array<{ status: string }> }; + expect(response.results.map((result) => result.status)).toEqual([ + "applied", + "applied", + "applied", + ]); + + // A is shown by nothing: its placeholder goes. B is still shown by the + // new lineup: its placeholder stays, so a duplicate waits for B. + expect(await rowsFor(t, "pending-a")).toEqual([]); + expect(await statusFor(owner, "pending-b")).toMatchObject({ + uploadStatus: "pending", + }); + await expect(duplicate(owner)).rejects.toThrow("still uploading"); + }); + + test("deleting several images in one batch checks lineups for each", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await placeImage(owner, "batch-shown"); + await placeImage(owner, "batch-alone"); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "lineup-shows-batch", + type: "lineup.add", + lineupPublicId: "lineup-shows-batch", + pagePublicId: firstPage, + payload: oneLineupPayload("lineup-shows-batch", { + images: [{ id: "batch-shown" }], + }), + sortIndex: 3, + }, + ], + }); + + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: ["batch-shown", "batch-alone"].map((elementPublicId) => ({ + opId: `batch-delete-${elementPublicId}`, + type: "element.delete", + elementPublicId, + pagePublicId: firstPage, + expectedElementRevision: 1, + })), + }); + + expect(await rowsFor(t, "batch-shown")).toHaveLength(1); + expect(await rowsFor(t, "batch-alone")).toEqual([]); + }); + + test("a lineup's image referenced before its upload is expected too", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + await owner.mutation(applyBatch, { + ...protocol, + strategyPublicId: source, + clientId: "slow-uploader", + ops: [ + { + opId: "late-link", + type: "lineup.add", + lineupPublicId: "late-link", + pagePublicId: firstPage, + payload: oneLineupPayload("late-link", { + images: [{ id: "late-link-image", fileExtension: ".png" }], + }), + sortIndex: 1, + }, + ], + }); + + expect(await statusFor(owner, "late-link-image")).toMatchObject({ + uploadStatus: "pending", + }); + await expect(duplicate(owner)).rejects.toThrow("still uploading"); + }); + + test("an image that already has bytes gets no placeholder", async () => { + const { t, owner } = await createHarness(); + await seedSource(t, owner); + // Legacy rows carry no strategy id. + await t.run(async (ctx) => { + await ctx.db.insert("imageAssets", { + publicId: "legacy-image", + provider: "r2", + objectKey: "legacy/legacy-image.png", + uploadStatus: "active", + createdAt: 1, + updatedAt: 1, + }); + }); + + await placeImage(owner, "legacy-image"); + + expect(await rowsFor(t, "legacy-image")).toHaveLength(1); + expect((await statusFor(owner, "legacy-image"))?.url).toBe( + "https://media.duplicate.test/legacy/legacy-image.png", + ); + }); +}); + +describe("strategies:duplicate access", () => { + async function share(owner: Harness, user: Harness, role: string) { + await owner.mutation(createShare, { + ...protocol, + targetType: "strategy", + targetPublicId: source, + token: `${role}-token`, + role, + }); + await user.mutation(redeemShare, { ...protocol, token: `${role}-token` }); + } + + test("a stranger cannot duplicate", async () => { + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await expect(duplicate(other)).rejects.toThrow("Forbidden"); + }); + + test("a viewer cannot duplicate, matching the library's Duplicate action", async () => { + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await share(owner, other, "viewer"); + await expect(duplicate(other)).rejects.toThrow("Forbidden"); + }); + + test("an editor duplicates into their own library with working images", async () => { + vi.useFakeTimers(); + const fetchMock = mockR2Deletes(); + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await share(owner, other, "editor"); + + await duplicate(other, "editor-copy"); + + const owned = (await other.query(listStrategies, { + scope: "owned", + })) as Array<{ publicId: string; role: string }>; + expect(owned).toMatchObject([{ publicId: "editor-copy", role: "owner" }]); + await expect( + owner.query(getFullSnapshot, { + ...protocol, + strategyPublicId: "editor-copy", + }), + ).rejects.toThrow("Forbidden"); + + await deleteAndSweep(t, owner, source); + expect(fetchMock).not.toHaveBeenCalled(); + expect(Object.keys(await imageUrls(other, "editor-copy"))).toHaveLength(2); + }); + + test("a publicId another user owns is a conflict", async () => { + const { t, owner, other } = await createHarness(); + await seedSource(t, owner); + await other.mutation(createStrategy, { + ...protocol, + publicId: "taken", + name: "Someone else's", + mapData: "ascent", + initialPagePublicId: "taken-page", + initialPageName: "Page 1", + initialPageIsAttack: true, + }); + await expect(duplicate(owner, "taken")).rejects.toThrow( + "Strategy publicId already exists", + ); + }); +}); diff --git a/convex/syncBoundaries.test.ts b/convex/syncBoundaries.test.ts new file mode 100644 index 00000000..d5365f43 --- /dev/null +++ b/convex/syncBoundaries.test.ts @@ -0,0 +1,2285 @@ +import { + convexTest, + type TestConvexForDataModel, + type TestConvexForDataModelAndIdentity, +} from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { DataModel } from "./_generated/dataModel"; +import { markAssetReferencesReady } from "./lib/assetReferences"; +import { + CLOUD_OPERATION_TOO_LARGE_MESSAGE, + CURRENT_CLOUD_PROTOCOL_VERSION, + MAX_CLOUD_ARRAY_ENTRIES, + MAX_CLOUD_OPERATION_BYTES, + serializedConvexValueUtf8Bytes, +} from "./lib/cloudProtocol"; +import schema from "./schema"; +import { + insertElement, + insertLineup, + oneLineupPayload, +} from "./testContent.helpers"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const createStrategyWithInitialPage = makeFunctionReference<"mutation">( + "strategies:createWithInitialPage", +); +const applyBatch = makeFunctionReference<"mutation">("ops:applyBatch"); +const getShell = makeFunctionReference<"query">("strategy:getShell"); +const getPageSnapshot = makeFunctionReference<"query">("page:getSnapshot"); +const getFullSnapshot = makeFunctionReference<"query">( + "strategy:getFullSnapshot", +); +const addPage = makeFunctionReference<"mutation">("pages:add"); +const deletePage = makeFunctionReference<"mutation">("pages:delete"); +const reorderPages = makeFunctionReference<"mutation">("pages:reorder"); +const createShare = makeFunctionReference<"mutation">("shares:create"); +const redeemShare = makeFunctionReference<"mutation">("shares:redeem"); + +const identity = { + issuer: "https://sync-boundaries.test", + subject: "owner", + tokenIdentifier: "sync-boundaries|owner", + name: "Sync Owner", +}; +const strategyPublicId = "strategy-sync-boundaries"; +const pageA = "page-a"; +const pageB = "page-b"; +const settingsA = { + agentSize: 48, + abilitySize: 32, + useNeutralTeamColors: false, +}; +const settingsB = { + agentSize: 52, + abilitySize: 36, + useNeutralTeamColors: true, +}; + +type Harness = TestConvexForDataModel; +type RootHarness = TestConvexForDataModelAndIdentity; + +function textPayload(text: string) { + return { + kind: "text" as const, + payloadVersion: 1, + data: { text, elementType: "text" }, + }; +} + +function imagePayload(assetPublicId: string) { + return { + kind: "image" as const, + payloadVersion: 1, + data: { id: assetPublicId, elementType: "image" }, + }; +} + +/// A group of one lineup showing one image; its row key is [lineupId]. +function lineupShowing(assetPublicId: string, lineupId = "restorable") { + return oneLineupPayload(lineupId, { + originId: "origin", + landingId: "landing", + name: "B lineup", + images: [{ id: assetPublicId }], + }); +} + +/** A ConvexError's code (convex-test passes its data as JSON text). */ +function errorCode(error: unknown): unknown { + const data = (error as { data?: unknown }).data; + return (typeof data === "string" ? JSON.parse(data) : data)?.code; +} + +async function createHarness(): Promise<{ + t: RootHarness; + owner: Harness; +}> { + const t = convexTest(schema, modules); + // A deployment whose reference backfill has run, as every one will be. + await t.run(markAssetReferencesReady); + const owner = t.withIdentity(identity); + await owner.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + return { t, owner }; +} + +async function createBaseStrategy(owner: Harness) { + await owner.mutation(createStrategyWithInitialPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "Sync boundary strategy", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "A active", + initialPageIsAttack: true, + initialPageSettings: settingsA, + }); +} + +async function applyOps( + owner: Harness, + clientId: string, + ops: Array>, +) { + return (await owner.mutation(applyBatch, { + strategyPublicId, + clientId, + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: ops.map(toProtocol3Op), + })) as { + strategyPublicId: string; + results: Array>; + }; +} + +function toProtocol3Op(op: Record): Record { + if (typeof op.type === "string") return op; + const opId = op.opId; + const kind = op.kind; + const entityType = op.entityType; + const expectedRevision = op.expectedRevision; + if (typeof opId !== "string" || typeof kind !== "string") { + throw new Error("Invalid test op"); + } + if (entityType === "strategy" && kind === "patch") { + return { + opId, + type: "strategy.patch", + payload: op.payload ?? {}, + expectedStrategyRevision: expectedRevision, + }; + } + if (entityType === "page") { + const pagePublicId = op.entityPublicId ?? op.pagePublicId; + if (kind === "add") { + return { + opId, + type: "page.add", + pagePublicId, + payload: op.payload ?? {}, + sortIndex: op.sortIndex ?? 0, + expectedStrategyRevision: expectedRevision, + }; + } + if (kind === "patch") { + return { + opId, + type: "page.patch", + pagePublicId, + payload: op.payload ?? {}, + expectedPageRevision: expectedRevision, + }; + } + if (kind === "delete") { + return { + opId, + type: "page.delete", + pagePublicId, + expectedStrategyRevision: expectedRevision, + }; + } + if (kind === "reorder") { + return { + opId, + type: "page.reorder", + pagePublicId, + sortIndex: op.sortIndex, + expectedStrategyRevision: expectedRevision, + }; + } + } + if (entityType === "pageContent" && kind === "patch") { + const payload = op.payload as { settings?: unknown } | undefined; + return { + opId, + type: "pageContent.patch", + pagePublicId: op.entityPublicId ?? op.pagePublicId, + settings: payload?.settings, + expectedPageContentRevision: expectedRevision, + }; + } + if (entityType === "element") { + return toProtocol3ContentOp(op, opId, kind, "element"); + } + if (entityType === "lineup") { + return toProtocol3ContentOp(op, opId, kind, "lineup"); + } + throw new Error(`Illegal test op pair: ${entityType}.${kind}`); +} + +function toProtocol3ContentOp( + op: Record, + opId: string, + kind: string, + entity: "element" | "lineup", +): Record { + const capitalized = entity === "element" ? "Element" : "Lineup"; + const idKey = `${entity}PublicId`; + const expectedKey = `expected${capitalized}Revision`; + return { + opId, + type: `${entity}.${kind}`, + [idKey]: op.entityPublicId, + ...({ pagePublicId: op.pagePublicId ?? pageA }), + ...(op.payload === undefined ? {} : { payload: op.payload }), + ...((kind === "add" || kind === "reorder") && op.sortIndex === undefined + ? { sortIndex: 0 } + : op.sortIndex === undefined + ? {} + : { sortIndex: op.sortIndex }), + ...(op.expectedRevision === undefined + ? {} + : { [expectedKey]: op.expectedRevision }), + }; +} + +async function getStrategyRow(t: Harness): Promise> { + return await t.run(async (ctx) => { + const row = await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + if (row === null) throw new Error("Missing strategy test row"); + return row as unknown as Record; + }); +} + +async function deleteOperationEvents(t: Harness, opIds: string[]) { + await t.run(async (ctx) => { + const events = await ctx.db.query("operationEvents").collect(); + for (const event of events) { + if (opIds.includes(event.opId)) { + await ctx.db.delete(event._id); + } + } + }); +} + +async function addPageB(owner: Harness, expectedRevision: number) { + const response = await applyOps(owner, "fixture-pages", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B inactive", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision, + }, + ]); + expect(response.results[0]).toMatchObject({ status: "applied" }); +} + +async function seedTwoPageContent(t: Harness, owner: Harness) { + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const strategy = await t.run(async (ctx) => { + return await ctx.db + .query("strategies") + .withIndex("by_publicId", (q) => q.eq("publicId", strategyPublicId)) + .first(); + }); + if (strategy === null) throw new Error("Missing strategy test row"); + const pages = await t.run(async (ctx) => { + return await ctx.db + .query("pages") + .withIndex("by_strategyId", (q) => q.eq("strategyId", strategy._id)) + .collect(); + }); + const pageAId = pages.find((page) => page.publicId === pageA)?._id; + const pageBId = pages.find((page) => page.publicId === pageB)?._id; + if (pageAId === undefined || pageBId === undefined) { + throw new Error("Missing test pages"); + } + + await t.run(async (ctx) => { + const now = Date.now(); + const assetA = "asset-a"; + const assetB = "asset-b"; + await ctx.db.insert("imageAssets", { + publicId: assetA, + provider: "r2", + strategyId: strategy._id, + objectKey: "tests/asset-a.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert("imageAssets", { + publicId: assetB, + provider: "r2", + strategyId: strategy._id, + objectKey: "tests/asset-b.png", + uploadStatus: "active", + fileExtension: ".png", + createdAt: now, + updatedAt: now, + }); + await insertElement(ctx, { + publicId: "element-a", + strategyId: strategy._id, + pageId: pageAId, + elementType: "image", + payloadKind: "image", + payloadVersion: 1, + payload: imagePayload(assetA), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await insertElement(ctx, { + publicId: "element-b", + strategyId: strategy._id, + pageId: pageBId, + elementType: "text", + payloadKind: "text", + payloadVersion: 1, + payload: textPayload("B only"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + await insertLineup(ctx, { + publicId: "lineup-b", + strategyId: strategy._id, + pageId: pageBId, + payloadKind: "lineups", + payloadVersion: 1, + payload: lineupShowing(assetB, "lineup-b"), + sortIndex: 0, + revision: 1, + deleted: false, + createdAt: now, + updatedAt: now, + }); + }); +} + +beforeAll(() => { + process.env.R2_PUBLIC_BASE_URL = "https://assets.sync-boundaries.test"; +}); + +describe("page-scoped read contract", () => { + test("strategy shell contains metadata and descriptors but no page content", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as Record; + expect(shell).toHaveProperty("header"); + expect(shell).toHaveProperty("pages"); + expect(shell).not.toHaveProperty("elements"); + expect(shell).not.toHaveProperty("lineups"); + expect(shell).not.toHaveProperty("assets"); + for (const page of shell.pages as Array>) { + expect(page).not.toHaveProperty("settings"); + } + }); + + test("one page snapshot excludes records and assets from other pages", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { + page: { publicId: string }; + content: { settings: unknown }; + elements: Array<{ publicId: string }>; + lineups: Array<{ publicId: string }>; + assets: Array<{ publicId: string }>; + }; + expect(snapshot.page).toMatchObject({ publicId: pageA }); + expect(snapshot.content).toMatchObject({ settings: settingsA }); + expect(snapshot.elements.map((item) => item.publicId)).toEqual([ + "element-a", + ]); + expect(snapshot.lineups).toEqual([]); + expect(snapshot.assets.map((item) => item.publicId)).toEqual(["asset-a"]); + }); + + test("full snapshot reconstructs every page and referenced asset", async () => { + const { t, owner } = await createHarness(); + await seedTwoPageContent(t, owner); + + const snapshot = (await owner.query(getFullSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; settings: unknown }>; + elements: Array<{ publicId: string }>; + lineups: Array<{ publicId: string }>; + assets: Array<{ publicId: string }>; + }; + expect(snapshot.pages).toMatchObject([ + { publicId: pageA, settings: settingsA }, + { publicId: pageB, settings: settingsB }, + ]); + expect(snapshot.elements.map((item) => item.publicId)).toEqual([ + "element-a", + "element-b", + ]); + expect(snapshot.lineups.map((item) => item.publicId)).toEqual([ + "lineup-b", + ]); + expect(snapshot.assets.map((item) => item.publicId).sort()).toEqual([ + "asset-a", + "asset-b", + ]); + }); + + test("page creation always creates one page content row", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const counts = await t.run(async (ctx) => { + const db = ctx.db as unknown as { + query(table: string): { collect(): Promise> }; + }; + return { + pages: (await db.query("pages").collect()).length, + pageContents: (await db.query("pageContents").collect()).length, + }; + }); + expect(counts).toEqual({ pages: 2, pageContents: 2 }); + }); + + test("structural page ops rename only auto-named pages", async () => { + vi.useFakeTimers(); + try { + const { t, owner } = await createHarness(); + await owner.mutation(createStrategyWithInitialPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + publicId: strategyPublicId, + name: "Page names", + mapData: "ascent", + initialPagePublicId: pageA, + initialPageName: "Page 1", + initialPageIsAutoNamed: true, + initialPageIsAttack: true, + initialPageSettings: settingsA, + }); + + expect( + await applyOps(owner, "page-name-provenance", [ + { + opId: "insert-page-before-a", + type: "page.add", + pagePublicId: pageB, + payload: { + name: "Page 1", + isAutoNamed: true, + isAttack: false, + settings: settingsB, + }, + sortIndex: 0, + expectedStrategyRevision: 0, + }, + ]), + ).toMatchObject({ results: [{ status: "applied" }] }); + + let shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + pages: Array<{ + publicId: string; + name: string; + isAutoNamed?: boolean; + revision: number; + }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, name: "Page 1", isAutoNamed: true }, + { publicId: pageA, name: "Page 2", isAutoNamed: true }, + ]); + + expect( + await applyOps(owner, "page-name-provenance", [ + { + opId: "rename-page-a", + type: "page.patch", + pagePublicId: pageA, + payload: { name: "Anchor", isAutoNamed: false }, + expectedPageRevision: shell.pages[1]!.revision, + }, + { + opId: "move-page-a-first", + type: "page.reorder", + pagePublicId: pageA, + sortIndex: 0, + expectedStrategyRevision: 1, + }, + ]), + ).toMatchObject({ + results: [{ status: "applied" }, { status: "applied" }], + }); + + shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as typeof shell; + expect(shell.pages).toMatchObject([ + { publicId: pageA, name: "Anchor", isAutoNamed: false }, + { publicId: pageB, name: "Page 2", isAutoNamed: true }, + ]); + + expect( + await applyOps(owner, "page-name-provenance", [ + { + opId: "delete-page-a", + type: "page.delete", + pagePublicId: pageA, + expectedStrategyRevision: 2, + }, + ]), + ).toMatchObject({ results: [{ status: "applied" }] }); + + shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as typeof shell; + expect(shell.pages).toMatchObject([ + { publicId: pageB, name: "Page 1", isAutoNamed: true }, + ]); + await t.finishAllScheduledFunctions(vi.runAllTimers); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("record-scoped write contract", () => { + test("content operations leave the strategy row unchanged", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "content-write", [ + { + opId: "add-element", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("before"), + sortIndex: 0, + }, + ]); + const before = await getStrategyRow(t); + + const response = await applyOps(owner, "content-write", [ + { + opId: "patch-element", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("after"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + expect(await getStrategyRow(t)).toEqual(before); + }); + + test("page settings update only their page content revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const beforeStrategy = await getStrategyRow(t); + const beforePage = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { page: { revision: number }; content: { revision: number } }; + + const response = await applyOps(owner, "page-content-write", [ + { + opId: "patch-page-content", + kind: "patch", + entityType: "pageContent", + entityPublicId: pageA, + payload: { settings: settingsB }, + expectedRevision: beforePage.content.revision, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "applied", + appliedRevision: beforePage.content.revision + 1, + }); + const afterPage = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { + page: { revision: number }; + content: { revision: number; settings: unknown }; + }; + expect(afterPage.page.revision).toBe(beforePage.page.revision); + expect(afterPage.content).toMatchObject({ + revision: beforePage.content.revision + 1, + settings: settingsB, + }); + expect(await getStrategyRow(t)).toEqual(beforeStrategy); + }); + + test("different entities and pages update from the same strategy revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + await applyOps(owner, "parallel-adds", [ + { + opId: "add-a", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("A"), + }, + { + opId: "add-b", + kind: "add", + entityType: "element", + entityPublicId: "element-b", + pagePublicId: pageB, + payload: textPayload("B"), + }, + ]); + + const response = await applyOps(owner, "parallel-patches", [ + { + opId: "patch-a", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("A2"), + expectedRevision: 1, + }, + { + opId: "patch-b", + kind: "patch", + entityType: "element", + entityPublicId: "element-b", + payload: textPayload("B2"), + expectedRevision: 1, + }, + ]); + expect(response.results.map((result) => result.status)).toEqual([ + "applied", + "applied", + ]); + }); + + test("one batch commits accepted ops and visibly rejects stale ops", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "same-entity", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + ]); + + const response = await applyOps(owner, "same-entity", [ + { + opId: "first", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("first"), + expectedRevision: 1, + }, + { + opId: "second", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("second"), + expectedRevision: 1, + }, + ]); + expect(response.results).toMatchObject([ + { status: "applied", appliedRevision: 2 }, + { + status: "rejected", + reason: "revision_mismatch", + current: { type: "element", revision: 2 }, + }, + ]); + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ + publicId: string; + revision: number; + payload: { data: { text: string } }; + }>; + }; + expect(snapshot.elements).toMatchObject([ + { + publicId: "element-a", + revision: 2, + payload: { data: { text: "first" } }, + }, + ]); + }); + + test("applyBatch throws NOT_FOUND only for a missing strategy", async () => { + // Clients rely on this: a thrown NOT_FOUND means the strategy is gone + // (never retried), while a missing page or element inside the batch is + // a per-op result. + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + const missingElement = await applyOps(owner, "missing-entity", [ + { + opId: "patch-missing", + kind: "patch", + entityType: "element", + entityPublicId: "element-that-never-existed", + payload: textPayload("orphan"), + expectedRevision: 1, + }, + ]); + expect(missingElement.results).toHaveLength(1); + expect(missingElement.results[0]).not.toMatchObject({ status: "applied" }); + + const missingStrategy = await owner + .mutation(applyBatch, { + strategyPublicId: "strategy-that-was-deleted", + clientId: "missing-strategy", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [], + }) + .catch((error: unknown) => error); + expect(errorCode(missingStrategy)).toBe("NOT_FOUND"); + + const missingShell = await owner + .query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId: "strategy-that-was-deleted", + }) + .catch((error: unknown) => error); + expect(errorCode(missingShell)).toBe("NOT_FOUND"); + }); + + test("page membership changes use the strategy revision", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const strategy = await getStrategyRow(t); + expect(strategy.revision).toBe(0); + + const accepted = await applyOps(owner, "page-membership", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision: 0, + }, + ]); + expect(accepted.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 1, + }); + + const rejected = await applyOps(owner, "page-membership", [ + { + opId: "add-stale-page", + kind: "add", + entityType: "page", + pagePublicId: "page-stale", + payload: { name: "stale", isAttack: true }, + sortIndex: 2, + expectedRevision: 0, + }, + ]); + expect(rejected.results[0]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { type: "strategy", revision: 1 }, + }); + }); + + test("page reorder uses the strategy revision", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const accepted = await applyOps(owner, "page-reorder", [ + { + opId: "move-page-b", + kind: "reorder", + entityType: "page", + entityPublicId: pageB, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(accepted.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 2 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + ]); + + const rejected = await applyOps(owner, "page-reorder", [ + { + opId: "stale-page-a", + kind: "reorder", + entityType: "page", + entityPublicId: pageA, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(rejected.results[0]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { type: "strategy", revision: 2 }, + }); + }); + + test("same-base page structure ops advance together in one batch", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + const result = await applyOps(owner, "offline-page-batch", [ + { + opId: "add-page-b-offline", + kind: "add", + entityType: "page", + entityPublicId: pageB, + payload: { name: "B", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision: 0, + }, + { + opId: "add-page-c-offline", + kind: "add", + entityType: "page", + entityPublicId: "page-c", + payload: { name: "C", isAttack: true, settings: settingsA }, + sortIndex: 2, + expectedRevision: 0, + }, + ]); + + expect(result.results).toMatchObject([ + { status: "applied", appliedRevision: 1 }, + { status: "applied", appliedRevision: 2 }, + ]); + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { pages: Array<{ publicId: string; sortIndex: number }> }; + expect(shell.pages).toMatchObject([ + { publicId: pageA, sortIndex: 0 }, + { publicId: pageB, sortIndex: 1 }, + { publicId: "page-c", sortIndex: 2 }, + ]); + }); + + test("a stale first page op does not open same-batch revision chaining", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await addPageB(owner, 0); + + const result = await applyOps(owner, "stale-offline-page-batch", [ + { + opId: "stale-add-page-c", + kind: "add", + entityType: "page", + entityPublicId: "page-c", + payload: { name: "C", isAttack: true, settings: settingsA }, + sortIndex: 2, + expectedRevision: 0, + }, + { + opId: "stale-add-page-d", + kind: "add", + entityType: "page", + entityPublicId: "page-d", + payload: { name: "D", isAttack: false, settings: settingsB }, + sortIndex: 3, + expectedRevision: 0, + }, + ]); + + expect(result.results).toMatchObject([ + { status: "rejected", reason: "revision_mismatch" }, + { status: "rejected", reason: "revision_mismatch" }, + ]); + }); + + test("page descriptor operations all apply through the durable batch protocol", async () => { + vi.useFakeTimers(); + try { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + + const renamed = await applyOps(owner, "page-descriptors", [ + { + opId: "rename-page-a", + kind: "patch", + entityType: "page", + entityPublicId: pageA, + payload: { name: "A renamed" }, + expectedRevision: 1, + }, + ]); + expect(renamed.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + + const added = await applyOps(owner, "page-descriptors", [ + { + opId: "add-page-b", + kind: "add", + entityType: "page", + entityPublicId: pageB, + payload: { name: "B", isAttack: false, settings: settingsB }, + sortIndex: 1, + expectedRevision: 0, + }, + ]); + expect(added.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 1, + }); + + const reordered = await applyOps(owner, "page-descriptors", [ + { + opId: "reorder-page-b", + kind: "reorder", + entityType: "page", + entityPublicId: pageB, + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(reordered.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + + const deleted = await applyOps(owner, "page-descriptors", [ + { + opId: "delete-page-b", + kind: "delete", + entityType: "page", + entityPublicId: pageB, + expectedRevision: 2, + }, + ]); + expect(deleted.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 3, + }); + + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + header: { revision: number }; + pages: Array<{ publicId: string; name: string; sortIndex: number }>; + }; + expect(shell.header.revision).toBe(3); + expect(shell.pages).toMatchObject([ + { publicId: pageA, name: "A renamed", sortIndex: 0 }, + ]); + await t.finishAllScheduledFunctions(vi.runAllTimers); + } finally { + vi.useRealTimers(); + } + }); + + test("page add at an occupied position shifts siblings", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + const response = await applyOps(owner, "page-insert", [ + { + opId: "insert-page-b-first", + kind: "add", + entityType: "page", + pagePublicId: pageB, + payload: { name: "B inactive", isAttack: false, settings: settingsB }, + sortIndex: 0, + expectedRevision: 0, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 1, + }); + + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 1 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + ]); + }); + + test("soft-deleted elements and lineups can be restored with their ids", async () => { + const elementId = "restorable-element"; + const lineupId = "restorable"; + const restoredText = "restored"; + const restoredAsset = "restored-asset"; + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "undo-restore", [ + { + opId: "add-element", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload("before delete"), + }, + { + opId: "add-lineup", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupShowing("before-delete-asset"), + }, + ]); + await applyOps(owner, "undo-restore", [ + { + opId: "delete-element", + kind: "delete", + entityType: "element", + entityPublicId: elementId, + expectedRevision: 1, + }, + { + opId: "delete-lineup", + kind: "delete", + entityType: "lineup", + entityPublicId: lineupId, + expectedRevision: 1, + }, + ]); + + const missingRevision = await applyOps(owner, "undo-restore-missing", [ + { + opId: "restore-element-missing", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + }, + { + opId: "restore-lineup-missing", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupShowing(restoredAsset), + }, + ]); + expect(missingRevision.results).toMatchObject([ + { + status: "rejected", + reason: "missing_expected_revision", + current: { type: "element", revision: 2 }, + }, + { + status: "rejected", + reason: "missing_expected_revision", + current: { type: "lineup", revision: 2 }, + }, + ]); + + const staleRevision = await applyOps(owner, "undo-restore-stale", [ + { + opId: "restore-element-stale", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + expectedRevision: 1, + }, + { + opId: "restore-lineup-stale", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupShowing(restoredAsset), + expectedRevision: 1, + }, + ]); + expect(staleRevision.results).toMatchObject([ + { + status: "rejected", + reason: "revision_mismatch", + current: { type: "element", revision: 2 }, + }, + { + status: "rejected", + reason: "revision_mismatch", + current: { type: "lineup", revision: 2 }, + }, + ]); + + const misclassifiedPatch = await applyOps(owner, "undo-restore-patch", [ + { + opId: "restore-element-patch", + kind: "patch", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload("before delete"), + sortIndex: 0, + expectedRevision: 2, + }, + { + opId: "restore-lineup-patch", + kind: "patch", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupShowing("before-delete-asset"), + sortIndex: 0, + expectedRevision: 2, + }, + ]); + // A patch never touches a tombstone, even one that would change nothing: + // only an add expecting the tombstone's revision brings the row back. + expect(misclassifiedPatch.results).toMatchObject([ + { + status: "rejected", + reason: "deleted", + current: { type: "element", revision: 2 }, + }, + { + status: "rejected", + reason: "deleted", + current: { type: "lineup", revision: 2 }, + }, + ]); + const stillDeleted = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ publicId: string; deleted: boolean }>; + lineups: Array<{ publicId: string; deleted: boolean }>; + }; + expect(stillDeleted.elements).toMatchObject([ + { publicId: elementId, deleted: true }, + ]); + expect(stillDeleted.lineups).toMatchObject([ + { publicId: lineupId, deleted: true }, + ]); + + const restored = await applyOps(owner, "undo-restore-current", [ + { + opId: "restore-element-current", + kind: "add", + entityType: "element", + entityPublicId: elementId, + pagePublicId: pageA, + payload: textPayload(restoredText), + expectedRevision: 2, + }, + { + opId: "restore-lineup-current", + kind: "add", + entityType: "lineup", + entityPublicId: lineupId, + pagePublicId: pageA, + payload: lineupShowing(restoredAsset), + expectedRevision: 2, + }, + ]); + expect(restored.results).toMatchObject([ + { status: "applied", appliedRevision: 3 }, + { status: "applied", appliedRevision: 3 }, + ]); + + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { + elements: Array<{ + publicId: string; + revision: number; + deleted: boolean; + payload: { data: { text: string } }; + }>; + lineups: Array<{ + publicId: string; + revision: number; + deleted: boolean; + payload: { data: { links: Array<{ images: Array<{ id: string }> }> } }; + }>; + }; + expect(snapshot.elements).toMatchObject([ + { + publicId: elementId, + revision: 3, + deleted: false, + payload: { data: { text: restoredText } }, + }, + ]); + expect(snapshot.lineups).toMatchObject([ + { + publicId: lineupId, + revision: 3, + deleted: false, + payload: { + data: { links: [{ images: [{ id: restoredAsset }] }] }, + }, + }, + ]); + }); + + test("direct page delete replay is idempotent after one page remains", async () => { + vi.useFakeTimers(); + try { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const added = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + })) as { revision: number }; + expect(added.revision).toBe(1); + + const deleted = (await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageB, + expectedRevision: 1, + })) as { revision: number; reused?: boolean }; + expect(deleted).toMatchObject({ revision: 2 }); + + const replayed = (await owner.mutation(deletePage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageB, + expectedRevision: 1, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 2, reused: true }); + await t.finishAllScheduledFunctions(vi.runAllTimers); + } finally { + vi.useRealTimers(); + } + }); + + test("direct page add replay ignores settings key order", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + }); + + const replayed = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: { + useNeutralTeamColors: true, + abilitySize: 36, + agentSize: 52, + }, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 1, reused: true }); + }); + + test("direct page add normalizes occupied and out-of-range positions", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 0, + isAttack: false, + settings: settingsB, + }); + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 1, + pagePublicId: "page-c", + name: "C", + sortIndex: 99, + isAttack: true, + }); + + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.pages).toMatchObject([ + { publicId: pageB, sortIndex: 0, revision: 1 }, + { publicId: pageA, sortIndex: 1, revision: 2 }, + { publicId: "page-c", sortIndex: 2, revision: 1 }, + ]); + + const replayed = (await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 1, + pagePublicId: "page-c", + name: "C", + sortIndex: 99, + isAttack: true, + })) as { revision: number; reused?: boolean }; + expect(replayed).toMatchObject({ revision: 2, reused: true }); + }); + + test("a lineup group whose lineup names a landing it does not hold is refused", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const { landings: _landings, ...withoutLandings } = lineupShowing( + "asset-empty", + "lineup-empty", + ).data; + + const response = await applyOps(owner, "empty-lineup", [ + { + opId: "add-empty-lineup", + kind: "add", + entityType: "lineup", + entityPublicId: "lineup-empty", + pagePublicId: pageA, + payload: { + kind: "lineups", + payloadVersion: 1, + data: { ...withoutLandings, landings: [] }, + }, + }, + { + opId: "add-lineup", + kind: "add", + entityType: "lineup", + entityPublicId: "lineup-full", + pagePublicId: pageA, + payload: lineupShowing("asset-full", "lineup-full"), + }, + ]); + + expect(response.results[0]).toMatchObject({ + opId: "add-empty-lineup", + status: "failed", + rawCode: "INVALID_LINEUP_PAYLOAD_DATA", + }); + expect(response.results[1]).toMatchObject({ + opId: "add-lineup", + status: "applied", + }); + }); + + test("old lineup shapes are no longer part of the contract", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + // A protocol 4 graph row (an origin, landing or link) passes argument + // validation, so an old client reaches the protocol gate, and the + // handler refuses it per op. + const graph = await applyOps(owner, "old-client", [ + { + opId: "add-lineupLink:k", + kind: "add", + entityType: "lineup", + entityPublicId: "lineupLink:k", + pagePublicId: pageA, + payload: { + kind: "lineupLink", + payloadVersion: 1, + data: { id: "k", originId: "o", landingId: "l", images: [] }, + }, + }, + ]); + expect(graph.results).toMatchObject([ + { + opId: "add-lineupLink:k", + status: "failed", + rawCode: "INVALID_LINEUP_PAYLOAD_KIND", + }, + ]); + // The legacy group before it, and the one-row-per-lineup shape tried + // before groups, are refused whole by argument validation. + for (const payload of [ + { + kind: "lineupGroup", + payloadVersion: 1, + data: { id: "legacy-group", items: [{ id: "item" }] }, + }, + { + kind: "lineup", + payloadVersion: 1, + data: { + id: "legacy-group", + origin: { id: "o", agent: { type: "sova" } }, + landing: { id: "l", ability: {} }, + }, + }, + ]) { + await expect( + applyOps(owner, "old-client", [ + { + opId: `add-legacy-${payload.kind}`, + kind: "add", + entityType: "lineup", + entityPublicId: "legacy-group", + pagePublicId: pageA, + payload, + }, + ]), + ).rejects.toThrow(/Validator error|ArgumentValidationError/); + } + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { lineups: unknown[] }; + expect(snapshot.lineups).toEqual([]); + }); + + test("direct page reorder rejects duplicate page ids", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await owner.mutation(addPage, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + expectedRevision: 0, + pagePublicId: pageB, + name: "B inactive", + sortIndex: 1, + isAttack: false, + settings: settingsB, + }); + + await expect( + owner.mutation(reorderPages, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + orderedPagePublicIds: [pageA, pageA], + expectedRevision: 1, + }), + ).rejects.toThrow("Page order must include each page exactly once"); + + const shell = (await owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + })) as { + header: { revision: number }; + pages: Array<{ publicId: string; sortIndex: number; revision: number }>; + }; + expect(shell.header.revision).toBe(1); + expect(shell.pages).toMatchObject([ + { publicId: pageA, sortIndex: 0, revision: 1 }, + { publicId: pageB, sortIndex: 1, revision: 1 }, + ]); + }); +}); + +/// A text element or a lineup named [label], to check the element and the +/// lineup write paths with the same steps. +function contentPayload(entity: "element" | "lineup", id: string, label: string) { + return entity === "element" + ? textPayload(label) + : oneLineupPayload(id, { name: label }); +} + +type ContentRow = { + publicId: string; + revision: number; + deleted: boolean; + sortIndex: number; + payload: unknown; +}; + +async function contentRow( + owner: Harness, + entity: "element" | "lineup", + id: string, +): Promise { + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { elements: ContentRow[]; lineups: ContentRow[] }; + const rows = entity === "element" ? snapshot.elements : snapshot.lineups; + return rows.find((row) => row.publicId === id); +} + +describe("a row a teammate deleted", () => { + for (const entity of ["element", "lineup"] as const) { + test(`a patch or reorder of a deleted ${entity} is refused whatever it expects; an add expecting the tombstone restores it`, async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const id = `deleted-${entity}`; + const base = contentPayload(entity, id, "base"); + const mine = contentPayload(entity, id, "mine"); + await applyOps(owner, "me", [ + { + opId: "add", + kind: "add", + entityType: entity, + entityPublicId: id, + pagePublicId: pageA, + payload: base, + }, + ]); + const deleted = await applyOps(owner, "teammate", [ + { + opId: "teammate-deletes", + kind: "delete", + entityType: entity, + entityPublicId: id, + expectedRevision: 1, + }, + ]); + expect(deleted.results).toMatchObject([ + { status: "applied", appliedRevision: 2 }, + ]); + const tombstone = await contentRow(owner, entity, id); + expect(tombstone).toMatchObject({ revision: 2, deleted: true }); + + const refused = { + status: "rejected", + reason: "deleted", + current: { type: entity, revision: 2, value: base }, + }; + // The edit made before the delete arrived. + const stale = await applyOps(owner, "me", [ + { + opId: "patch-expecting-1", + kind: "patch", + entityType: entity, + entityPublicId: id, + payload: mine, + expectedRevision: 1, + }, + ]); + expect(stale.results).toMatchObject([refused]); + // What an old "Keep mine" sent: the same edit against the tombstone. + const keepMine = await applyOps(owner, "me", [ + { + opId: "patch-expecting-2", + kind: "patch", + entityType: entity, + entityPublicId: id, + payload: mine, + expectedRevision: 2, + }, + { + opId: "identical-patch-expecting-2", + kind: "patch", + entityType: entity, + entityPublicId: id, + payload: base, + expectedRevision: 2, + }, + { + opId: "reorder-expecting-1", + kind: "reorder", + entityType: entity, + entityPublicId: id, + sortIndex: 5, + expectedRevision: 1, + }, + { + opId: "reorder-expecting-2", + kind: "reorder", + entityType: entity, + entityPublicId: id, + sortIndex: 5, + expectedRevision: 2, + }, + ]); + expect(keepMine.results).toMatchObject([ + refused, + refused, + refused, + refused, + ]); + // None of it touched the tombstone. + expect(await contentRow(owner, entity, id)).toEqual(tombstone); + + const restored = await applyOps(owner, "me", [ + { + opId: "restore", + kind: "add", + entityType: entity, + entityPublicId: id, + pagePublicId: pageA, + payload: mine, + expectedRevision: 2, + }, + ]); + expect(restored.results).toMatchObject([ + { status: "applied", appliedRevision: 3 }, + ]); + const row = await contentRow(owner, entity, id); + expect(row).toMatchObject({ revision: 3, deleted: false }); + expect(row!.payload).toEqual(mine); + }); + } +}); + +describe("a write that changes nothing", () => { + for (const entity of ["element", "lineup"] as const) { + test(`two clients sending the same ${entity} patch from one revision: one applies, the other is a noop`, async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const id = `same-${entity}`; + const base = contentPayload(entity, id, "base"); + const edited = contentPayload(entity, id, "edited"); + await applyOps(owner, "setup", [ + { + opId: "add", + kind: "add", + entityType: entity, + entityPublicId: id, + pagePublicId: pageA, + payload: base, + }, + ]); + const patch = { + kind: "patch", + entityType: entity, + entityPublicId: id, + payload: edited, + expectedRevision: 1, + }; + const first = await applyOps(owner, "client-a", [ + { ...patch, opId: "a-edits" }, + ]); + const second = await applyOps(owner, "client-b", [ + { ...patch, opId: "b-edits" }, + ]); + expect(first.results).toMatchObject([ + { status: "applied", appliedRevision: 2 }, + ]); + expect(second.results).toMatchObject([ + { status: "noop", currentRevision: 2 }, + ]); + + // An add of what the live row already holds, on its page and at its + // place, is a noop too, whatever revision it expects. + const added = await applyOps(owner, "client-c", [ + { + opId: "c-adds-stale", + kind: "add", + entityType: entity, + entityPublicId: id, + pagePublicId: pageA, + payload: edited, + expectedRevision: 1, + }, + { + opId: "c-adds-unversioned", + kind: "add", + entityType: entity, + entityPublicId: id, + pagePublicId: pageA, + payload: edited, + }, + ]); + expect(added.results).toMatchObject([ + { status: "noop", currentRevision: 2 }, + { status: "noop", currentRevision: 2 }, + ]); + const row = await contentRow(owner, entity, id); + expect(row).toMatchObject({ revision: 2, deleted: false }); + expect(row!.payload).toEqual(edited); + }); + } +}); + +describe("a resent op whose ack was lost", () => { + test("reports the revision it landed at, so a successor cannot overwrite a teammate's later edit", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "first-client", [ + { + opId: "add", + type: "element.add", + elementPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + sortIndex: 0, + }, + ]); + const patch = (opId: string, text: string, expectedElementRevision: number) => ({ + opId, + type: "element.patch", + elementPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload(text), + expectedElementRevision, + }); + const mine = patch("mine", "mine", 1); + const first = await applyOps(owner, "first-client", [mine]); + expect(first.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 2, + }); + + // A teammate's device edits the element after it. + const teammate = await applyOps(owner, "teammate-client", [ + patch("theirs", "theirs", 2), + ]); + expect(teammate.results[0]).toMatchObject({ + status: "applied", + appliedRevision: 3, + }); + + // The first client never heard back: it sends the op again with the + // same id, and the successor it queued on top of it. + const resent = await applyOps(owner, "first-client", [ + mine, + patch("mine-next", "mine again", 2), + ]); + expect(resent.results[0]).toEqual({ + opId: "mine", + status: "noop", + currentRevision: 2, + }); + expect(resent.results[1]).toMatchObject({ + opId: "mine-next", + status: "rejected", + reason: "revision_mismatch", + current: { type: "element", revision: 3, value: textPayload("theirs") }, + }); + const row = await t.run(async (ctx) => + (await ctx.db.query("elements").collect()).find( + (element) => element.publicId === "element-a", + ), + ); + expect(row).toMatchObject({ revision: 3, payload: textPayload("theirs") }); + }); +}); + +describe("replay safety after operation event expiry", () => { + test("identical add acknowledges and different add rejects", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const original = { + opId: "replayed-add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("original"), + sortIndex: 0, + }; + await applyOps(owner, "replay-add", [original]); + await deleteOperationEvents(t, ["replayed-add"]); + + const identical = await applyOps(owner, "replay-add", [original]); + expect(identical.results[0]).toMatchObject({ + status: "noop", + }); + + const different = await applyOps(owner, "replay-add-different", [ + { ...original, opId: "different-add", payload: textPayload("different") }, + ]); + expect(different.results[0]).toMatchObject({ + status: "rejected", + reason: "already_exists", + }); + }); + + test("matching stale patch acknowledges before revision rejection", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-patch", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + { + opId: "patch", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("desired"), + expectedRevision: 1, + }, + ]); + await deleteOperationEvents(t, ["patch"]); + + const response = await applyOps(owner, "replay-patch", [ + { + opId: "patch", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("desired"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "noop", + currentRevision: 2, + }); + }); + + test("different stale patch rejects with the current entity", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-patch-different", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + }, + { + opId: "advance", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("newer"), + expectedRevision: 1, + }, + ]); + const response = await applyOps(owner, "replay-patch-different", [ + { + opId: "stale", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("different"), + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "rejected", + reason: "revision_mismatch", + current: { + type: "element", + revision: 2, + value: textPayload("newer"), + }, + }); + }); + + test("delete against a missing row acknowledges as a no-op", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const response = await applyOps(owner, "replay-delete", [ + { + opId: "missing-delete", + kind: "delete", + entityType: "element", + entityPublicId: "missing-element", + expectedRevision: 4, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "noop", + }); + }); + + test("reorder already at the desired position acknowledges before revision rejection", async () => { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + await applyOps(owner, "replay-reorder", [ + { + opId: "add", + kind: "add", + entityType: "element", + entityPublicId: "element-a", + pagePublicId: pageA, + payload: textPayload("base"), + sortIndex: 0, + }, + { + opId: "advance", + kind: "patch", + entityType: "element", + entityPublicId: "element-a", + payload: textPayload("newer"), + expectedRevision: 1, + }, + ]); + await deleteOperationEvents(t, ["advance"]); + + const response = await applyOps(owner, "replay-reorder", [ + { + opId: "reorder", + kind: "reorder", + entityType: "element", + entityPublicId: "element-a", + sortIndex: 0, + expectedRevision: 1, + }, + ]); + expect(response.results[0]).toMatchObject({ + status: "noop", + currentRevision: 2, + }); + }); +}); + +describe("a batch bound to its account", () => { + const editorIdentity = { + issuer: "https://sync-boundaries.test", + subject: "editor", + tokenIdentifier: "sync-boundaries|editor", + name: "Sync Editor", + }; + + /// The owner's strategy, with a second account that may edit it: the + /// account an owner's batch would land as if it reached the server under + /// the editor's sign-in. + async function createSharedHarness() { + const { t, owner } = await createHarness(); + await createBaseStrategy(owner); + const editor = t.withIdentity(editorIdentity); + await editor.mutation(ensureCurrentUser, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + }); + await owner.mutation(createShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + targetType: "strategy", + targetPublicId: strategyPublicId, + token: "bound-batch-editor-token", + role: "editor", + }); + await editor.mutation(redeemShare, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + token: "bound-batch-editor-token", + }); + return { t, owner, editor }; + } + + function boundBatch(accountSubject: string | undefined) { + return { + strategyPublicId, + clientId: "owner-device", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + toProtocol3Op({ + opId: "owner-add", + kind: "add", + entityType: "element", + entityPublicId: "owner-element", + pagePublicId: pageA, + payload: textPayload("owner's work"), + }), + ], + ...(accountSubject === undefined ? {} : { accountSubject }), + }; + } + + async function elementIds(owner: Harness) { + const snapshot = (await owner.query(getPageSnapshot, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + pagePublicId: pageA, + })) as { elements: Array<{ publicId: string }> }; + return snapshot.elements.map((element) => element.publicId); + } + + test("applies when the caller is the account", async () => { + const { owner } = await createSharedHarness(); + + const response = (await owner.mutation( + applyBatch, + boundBatch(identity.subject), + )) as { results: Array> }; + + expect(response.results).toMatchObject([{ status: "applied" }]); + expect(await elementIds(owner)).toEqual(["owner-element"]); + }); + + test("refuses the whole batch under another account, recording nothing", async () => { + const { t, owner, editor } = await createSharedHarness(); + + const refused = await editor + .mutation(applyBatch, boundBatch(identity.subject)) + .catch((error: unknown) => error); + expect(errorCode(refused)).toBe("FORBIDDEN"); + const signedOut = await t + .mutation(applyBatch, boundBatch(identity.subject)) + .catch((error: unknown) => error); + expect(errorCode(signedOut)).toBe("UNAUTHENTICATED"); + + expect(await elementIds(owner)).toEqual([]); + const events = await t.run((ctx) => + ctx.db.query("operationEvents").collect(), + ); + expect(events).toEqual([]); + + // The op id is still unknown to the server, so the owner's next session + // lands the same work instead of replaying a refusal. + const resent = (await owner.mutation( + applyBatch, + boundBatch(identity.subject), + )) as { results: Array> }; + expect(resent.results).toMatchObject([{ status: "applied" }]); + expect(await elementIds(owner)).toEqual(["owner-element"]); + }); + + test("an unbound batch from an older client lands as the caller", async () => { + const { owner, editor } = await createSharedHarness(); + + const response = (await editor.mutation( + applyBatch, + boundBatch(undefined), + )) as { results: Array> }; + + expect(response.results).toMatchObject([{ status: "applied" }]); + expect(await elementIds(owner)).toEqual(["owner-element"]); + }); +}); + +describe("cloud protocol v3 boundary", () => { + test("old clients receive a structured upgrade error", async () => { + const { owner } = await createHarness(); + + const error = await owner + .mutation(applyBatch, { + strategyPublicId, + clientId: "old-client", + clientProtocolVersion: 2, + ops: [], + }) + .then( + () => null, + (caught: unknown) => caught as { data?: unknown }, + ); + expect(error).not.toBeNull(); + expect(typeof error?.data).toBe("string"); + expect(JSON.parse(error?.data as string)).toEqual({ + code: "CLIENT_UPGRADE_REQUIRED", + message: "Client upgrade required", + }); + }); + + test("a policy-oversized Unicode op fails without blocking its sibling", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const oversized = { + opId: "oversized-drawing", + type: "element.add" as const, + elementPublicId: "large-element", + pagePublicId: pageA, + payload: { + kind: "drawing" as const, + payloadVersion: 1, + data: { + elementType: "drawing", + encodedPoints: "界".repeat(310_000), + }, + }, + sortIndex: 0, + }; + const serializedBytes = serializedConvexValueUtf8Bytes(oversized); + expect(serializedBytes).toBeGreaterThan(MAX_CLOUD_OPERATION_BYTES); + expect(serializedBytes).toBeLessThan(1024 * 1024); + + const response = (await owner.mutation(applyBatch, { + strategyPublicId, + clientId: "oversized-op-client", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + oversized, + { + opId: "independent-strategy-patch", + type: "strategy.patch", + payload: { name: "Independent change landed" }, + expectedStrategyRevision: 0, + }, + ], + })) as { results: Array> }; + + expect(response.results).toEqual([ + { + opId: "oversized-drawing", + status: "failed", + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, + }, + { + opId: "independent-strategy-patch", + status: "applied", + appliedRevision: 1, + }, + ]); + await expect( + owner.query(getShell, { + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + strategyPublicId, + }), + ).resolves.toMatchObject({ + header: { name: "Independent change landed", revision: 1 }, + }); + }); + + test("a policy-wide array fails per op without blocking its sibling", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + const response = (await owner.mutation(applyBatch, { + strategyPublicId, + clientId: "wide-array-client", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + { + opId: "wide-drawing", + type: "element.add", + elementPublicId: "wide-element", + pagePublicId: pageA, + payload: { + kind: "drawing", + payloadVersion: 1, + data: { + points: Array.from( + { length: MAX_CLOUD_ARRAY_ENTRIES + 1 }, + () => 0, + ), + }, + }, + sortIndex: 0, + }, + { + opId: "independent-wide-array-sibling", + type: "strategy.patch", + payload: { name: "Wide sibling landed" }, + expectedStrategyRevision: 0, + }, + ], + })) as { results: Array> }; + + expect(response.results).toEqual([ + { + opId: "wide-drawing", + status: "failed", + code: "INVALID_PAYLOAD", + rawCode: "INVALID_PAYLOAD", + message: CLOUD_OPERATION_TOO_LARGE_MESSAGE, + }, + { + opId: "independent-wide-array-sibling", + status: "applied", + appliedRevision: 1, + }, + ]); + }); + + test("the wire validator rejects an illegal operation discriminator", async () => { + const { owner } = await createHarness(); + + await expect( + owner.mutation(applyBatch, { + strategyPublicId, + clientId: "illegal-op", + clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION, + ops: [ + { + opId: "illegal-page-delete", + type: "page.delete", + elementPublicId: "element-a", + expectedStrategyRevision: 0, + }, + ], + }), + ).rejects.toThrow(); + }); + + test("function errors become closed failed outcomes", async () => { + const { owner } = await createHarness(); + await createBaseStrategy(owner); + + const response = await applyOps(owner, "failed-outcome", [ + { + opId: "page-settings-on-descriptor", + type: "page.patch", + pagePublicId: pageA, + payload: { settings: settingsB }, + expectedPageRevision: 1, + }, + ]); + + expect(response.results[0]).toEqual({ + opId: "page-settings-on-descriptor", + status: "failed", + code: "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + rawCode: "PAGE_SETTINGS_REQUIRE_PAGE_CONTENT", + message: "Page settings require a pageContent operation", + }); + }); +}); diff --git a/convex/test.setup.ts b/convex/test.setup.ts new file mode 100644 index 00000000..c4a3523e --- /dev/null +++ b/convex/test.setup.ts @@ -0,0 +1,3 @@ +/// + +export const modules = import.meta.glob("./**/!(*.*.*)*.*s"); diff --git a/convex/testContent.helpers.ts b/convex/testContent.helpers.ts new file mode 100644 index 00000000..b3f262ba --- /dev/null +++ b/convex/testContent.helpers.ts @@ -0,0 +1,141 @@ +// Test helpers. The second dot in the file name keeps Convex from bundling +// it into deployments. +// Inserts content rows the way every server writer does: together with the +// image reference rows media cleanup reads (see lib/assetReferences.ts). +import type { Doc, Id } from "./_generated/dataModel"; +import type { MutationCtx } from "./_generated/server"; +import type { WithoutSystemFields } from "convex/server"; +import { + syncElementAssetReferences, + syncLineupAssetReferences, +} from "./lib/assetReferences"; +import { syncLineupAgents } from "./lib/strategyAgentSummary"; +import { syncLineupItems } from "./lib/lineupItems"; + +export async function insertElement( + ctx: MutationCtx, + element: WithoutSystemFields>, +): Promise> { + const id = await ctx.db.insert("elements", element); + await syncElementAssetReferences(ctx, id, element); + return id; +} + +export async function insertLineup( + ctx: MutationCtx, + lineup: WithoutSystemFields>, +): Promise> { + const id = await ctx.db.insert("lineups", lineup); + await syncLineupAssetReferences(ctx, id, lineup); + await syncLineupAgents(ctx, id, lineup); + await syncLineupItems(ctx, id, lineup); + return id; +} + +type Position = { dx: number; dy: number }; + +export type TestOrigin = { + id: string; + agentType?: string; + position?: Position; +}; + +export type TestLanding = { id: string; position?: Position }; + +export type TestLink = { + id: string; + originId: string; + landingId: string; + name?: string; + youtubeLink?: string; + notes?: string; + images?: Array<{ id: string; fileExtension?: string }>; +}; + +export type TestLineupGroup = { + origins: TestOrigin[]; + landings: TestLanding[]; + links: TestLink[]; +}; + +/// A lineup group row's payload as a client writes it: the group's id (the +/// row's key), its origins (each placing an agent), landings (each placing +/// an ability) and links, each in the shape of the Dart model's toJson() +/// (LineUpOrigin, LineUpLanding, LineUpLink), each marker's `lineUpID` +/// naming its spot. +export function lineupsPayload(id: string, group: TestLineupGroup) { + return { + kind: "lineups" as const, + payloadVersion: 1, + data: { + id, + origins: group.origins.map((origin) => ({ + id: origin.id, + agent: { + id: `agent-${origin.id}`, + type: origin.agentType ?? "sova", + position: origin.position ?? { dx: 0, dy: 0 }, + lineUpID: origin.id, + }, + })), + landings: group.landings.map((landing) => ({ + id: landing.id, + ability: { + id: `ability-${landing.id}`, + position: landing.position ?? { dx: 0, dy: 0 }, + lineUpID: landing.id, + }, + })), + links: group.links.map((link) => ({ + id: link.id, + originId: link.originId, + landingId: link.landingId, + name: link.name ?? "", + youtubeLink: link.youtubeLink ?? "", + notes: link.notes ?? "", + images: link.images ?? [], + })), + }, + }; +} + +export type TestLineup = { + originId?: string; + landingId?: string; + agentType?: string; + originPosition?: Position; + landingPosition?: Position; + name?: string; + youtubeLink?: string; + notes?: string; + images?: Array<{ id: string; fileExtension?: string }>; +}; + +/// A group row holding one lineup: one origin, one landing and the link +/// between them. The group and its link take [id]; the spots default to +/// `-origin` and `-landing`. +export function oneLineupPayload(id: string, lineup: TestLineup = {}) { + const originId = lineup.originId ?? `${id}-origin`; + const landingId = lineup.landingId ?? `${id}-landing`; + return lineupsPayload(id, { + origins: [ + { + id: originId, + agentType: lineup.agentType, + position: lineup.originPosition, + }, + ], + landings: [{ id: landingId, position: lineup.landingPosition }], + links: [ + { + id, + originId, + landingId, + name: lineup.name, + youtubeLink: lineup.youtubeLink, + notes: lineup.notes, + images: lineup.images, + }, + ], + }); +} diff --git a/convex/tsconfig.json b/convex/tsconfig.json new file mode 100644 index 00000000..41bfbb9b --- /dev/null +++ b/convex/tsconfig.json @@ -0,0 +1,25 @@ +{ + /* This TypeScript project config describes the environment that + * Convex functions run in and is used to typecheck them. + * You can modify it, but some settings are required to use Convex. + */ + "compilerOptions": { + /* These settings are not required by Convex and can be modified. */ + "allowJs": true, + "strict": true, + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "skipLibCheck": true, + "allowSyntheticDefaultImports": true, + + /* These compiler options are required by Convex */ + "target": "ESNext", + "lib": ["ES2023", "dom"], + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"], + "exclude": ["./_generated"] +} diff --git a/convex/users.test.ts b/convex/users.test.ts new file mode 100644 index 00000000..e78c4bef --- /dev/null +++ b/convex/users.test.ts @@ -0,0 +1,67 @@ +import { convexTest } from "convex-test"; +import { makeFunctionReference } from "convex/server"; +import { describe, expect, test } from "vitest"; +import { CURRENT_CLOUD_PROTOCOL_VERSION } from "./lib/cloudProtocol"; +import schema from "./schema"; +import { modules } from "./test.setup"; + +const ensureCurrentUser = makeFunctionReference<"mutation">( + "users:ensureCurrentUser", +); +const me = makeFunctionReference<"query">("users:me"); +const protocol = { clientProtocolVersion: CURRENT_CLOUD_PROTOCOL_VERSION }; + +const base = { issuer: "https://users.test" }; + +describe("users:ensureCurrentUser", () => { + test("stores the Discord profile Convex hands over as flattened claims", async () => { + const t = convexTest(schema, modules); + const discord = t.withIdentity({ + ...base, + subject: "discord", + tokenIdentifier: "users|discord", + email: "ana@example.com", + "user_metadata.full_name": "ana_lyst", + "user_metadata.custom_claims.global_name": "Ana", + "user_metadata.avatar_url": "https://cdn.discordapp.com/avatars/1/a.png", + }); + await discord.mutation(ensureCurrentUser, protocol); + expect(await discord.query(me, {})).toMatchObject({ + displayName: "Ana", + avatarUrl: "https://cdn.discordapp.com/avatars/1/a.png", + }); + }); + + test("refreshes a stored placeholder on the next sign-in", async () => { + const t = convexTest(schema, modules); + const claims = { ...base, subject: "later", tokenIdentifier: "users|later" }; + await t.withIdentity(claims).mutation(ensureCurrentUser, protocol); + expect(await t.withIdentity(claims).query(me, {})).toMatchObject({ + displayName: "Discord user", + avatarUrl: null, + }); + + const signedInAgain = t.withIdentity({ + ...claims, + "user_metadata.full_name": "later_player", + }); + await signedInAgain.mutation(ensureCurrentUser, protocol); + expect(await signedInAgain.query(me, {})).toMatchObject({ + displayName: "later_player", + }); + }); + + test("names an email sign-in by its address", async () => { + const t = convexTest(schema, modules); + const email = t.withIdentity({ + ...base, + subject: "email", + tokenIdentifier: "users|email", + email: "sam.smokes@example.com", + }); + await email.mutation(ensureCurrentUser, protocol); + expect(await email.query(me, {})).toMatchObject({ + displayName: "sam.smokes", + }); + }); +}); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 00000000..29c35c2b --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,87 @@ +import { mutation, query } from "./_generated/server"; +import { + findUserByIdentity, + getCanonicalExternalId, +} from "./lib/auth"; +import { unauthenticatedError } from "./lib/errors"; +import { profileFromIdentity, UNKNOWN_DISPLAY_NAME } from "./lib/profile"; +import { + assertSupportedCloudProtocol, + cloudProtocolArgs, +} from "./lib/cloudProtocol"; +import { okResultValidator } from "./lib/publicValidators"; +import { v } from "convex/values"; + +export const ensureCurrentUser = mutation({ + args: cloudProtocolArgs, + returns: okResultValidator, + handler: async (ctx, args) => { + assertSupportedCloudProtocol(args.clientProtocolVersion); + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const externalId = getCanonicalExternalId(identity); + const profile = profileFromIdentity(identity); + const displayName = profile.name ?? UNKNOWN_DISPLAY_NAME; + const avatarUrl = profile.avatar ?? undefined; + + const existingUser = await findUserByIdentity(ctx, identity); + + if (existingUser !== null) { + await ctx.db.patch(existingUser._id, { + externalId, + displayName, + avatarUrl, + updatedAt: Date.now(), + }); + return { ok: true as const }; + } + + await ctx.db.insert("users", { + externalId, + displayName, + avatarUrl, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + return { ok: true as const }; + }, +}); + +export const me = query({ + args: {}, + returns: v.union( + v.object({ + id: v.string(), + externalId: v.string(), + displayName: v.string(), + avatarUrl: v.union(v.string(), v.null()), + createdAt: v.number(), + updatedAt: v.number(), + }), + v.null(), + ), + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + return null; + } + + const user = await findUserByIdentity(ctx, identity); + + if (user === null) { + return null; + } + + return { + id: user.externalId, + externalId: user.externalId, + displayName: user.displayName, + avatarUrl: user.avatarUrl ?? null, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + }, +}); diff --git a/docs/adr/0001-icarus-owns-typed-convex-wrapper.md b/docs/adr/0001-icarus-owns-typed-convex-wrapper.md new file mode 100644 index 00000000..c31e8d6f --- /dev/null +++ b/docs/adr/0001-icarus-owns-typed-convex-wrapper.md @@ -0,0 +1,270 @@ +# Keep the typed Convex wrapper inside Icarus + +The typed Convex wrapper is an Icarus-owned integration layer. Convex's function +spec is the sole source for its modules, functions, parameters, and structural +result types. Code generation writes the complete Dart API, private structural +wire models, and transport implementation. Those wire models stay inside the +collaboration repositories. Exact tagged payload bindings reuse existing +Icarus types where the server declares their identity. No `dynamic`, `Object?`, +raw map, or stringly typed enum may escape the wrapper, and private decoders +must return either a typed value or a typed decoding failure. + +The generated API uses Dart's pure-interface and redirecting-factory features. +The root client injects its transport through a factory that redirects to the +private generated implementation. Generated module APIs remain pure interfaces +obtained through root getters: + +```dart +abstract interface class IcarusConvexApi { + factory IcarusConvexApi(ConvexTransport transport) = + _$IcarusConvexApi; + + FoldersApi get folders; +} + +abstract interface class FoldersApi { + ConvexQuery> listTree(); +} +``` + +Authored Dart does not repeat ordinary functions, parameters, or result types. +Its only generation configuration binds an opaque, server-declared payload tag +to one existing Icarus type and codec. The generator never matches payloads by +structure or endpoint. Bindings have no wildcard, ordering, fallback, or +endpoint-specific override; a tag has exactly one meaning everywhere it +appears. Missing, extra, or duplicate bindings stop the build. + +For an ordinary closed object validator with no domain tag, the generator emits +a private typed structural model. Repositories map that value into the Icarus +object the app uses. These generated models replace hand-written JSON DTOs such +as `CloudFolderSummary`; their `fromJson` plumbing disappears, and they never +cross the repository boundary. + +App code calls named repository methods. Repository code may build sealed, +typed operation objects internally, but callers do not assemble generic +`StrategyOp` values or choose string route names. + +The generated client covers every public Convex function that a client may +call, including functions Icarus does not call yet. Convex functions meant only +for backend jobs become internal functions instead of appearing in the Dart +API. This full coverage is deliberate: a client-callable function missing from +the generated Dart API is a contract failure, not an allowed undeclared +endpoint. Icarus accepts the extra declarations because they expose accidental +public functions and contract drift before app code depends on them. + +Only the collaboration data layer imports the generated client. Widgets and +providers call hand-written repositories with named domain methods. An +architecture test rejects generated-client imports outside the allowed +directory. + +Public Convex functions accept and return Icarus-owned public IDs. They resolve +those IDs to Convex document IDs on the server, and Convex document IDs remain +inside backend functions. The generator rejects `v.id(...)` in a public +function unless the endpoint has an explicitly approved infrastructure-handle +exception. Existing leaks must be removed before generation: user functions +must stop returning user document IDs, and the image upload handshake must use +an Icarus-owned upload-attempt ID instead of persisting an `imageAssets` ID in +the local queue. Existing Icarus public IDs remain `String` values. + +Generated methods use ordinary nullable named parameters when a Convex field +has only two states: absent or present. The generator introduces a presence +wrapper only when the validator permits three distinct states: absent, `null`, +and a value. + +One-shot calls return `Future` and subscriptions return `Stream`. They +report failures through a sealed exception hierarchy rather than wrapping every +value in a result object. Repositories translate those exceptions into Icarus +sync states. + +Client-side failures have distinct exception types for transport, timeout, and +decoding failures. A function failure carries a generated `ConvexErrorCode` +enum and the server message instead of generating one exception class per +server code. + +`convex/lib/errors.ts` owns one machine-readable error-code catalog. Its +`ErrorCode` type and every structured error helper derive from that catalog, +and the contract snapshot task writes its scrubbed values beside the function +spec. The Dart generator reads that snapshot to emit `ConvexErrorCode`; it does +not scrape throw sites or maintain a second hand-written list. + +Both raw transports must preserve structured Convex error codes and data. The +wrapper never derives a code from a human-readable message. Transport contract +tests prove that native and web produce the same private error value. If +`convex_flutter` cannot expose the structured payload, Icarus extends its fork +before building generated error mapping. + +Subscriptions survive connection loss and authentication refresh. Those states +remain on their existing separate streams. A recoverable function error enters +the typed data stream without closing it. A decoding or contract failure +cancels and closes the subscription because Icarus cannot trust later values +from the same mismatched contract. + +Convex's generated function-spec JSON is the wire contract. The generator walks +every client-callable public function in that spec and emits the entire Dart +API. Generation fails on a missing argument or return validator, an unsupported +validator, `v.any()`, an opaque value without a server discriminator and exact +binding, any missing, extra, or duplicate binding, a non-injective Dart name +conversion, a collision with generated client or `Object` members, or any shape +that would escape as an untyped value. It never auto-suffixes a collision or +guesses a domain type. The generator remains Icarus-owned and only promises to +support Icarus; its internal package layout is not a commitment to publish a +general Convex package. + +The repository commits a scrubbed function-spec snapshot. Developers refresh +it after backend contract changes, and ordinary Dart generation reads the local +snapshot instead of requiring a live Convex deployment. CI also deploys the +current backend to an isolated Convex environment, obtains and scrubs a fresh +function spec, and fails if it differs from the committed snapshot. A stale +snapshot therefore cannot make generation appear clean. + +The repository also commits the generated Dart client. CI regenerates it and +fails when the working tree changes. All output lives as standalone libraries +under `lib/collab/generated/`; authored code never lives there, and generated +files never use `part` or `part of` with authored libraries. Each run recreates +the owned output set so a renamed function cannot leave a stale Dart file. +Developers edit Convex validators or the opaque-payload binding file, never +generated files. + +Canonical element and lineup JSON remains private to the wrapper. Their exact +tag bindings make generated methods accept and return existing typed Icarus +objects at those payload positions. Convex validates each payload's kind, +version, and JSON envelope; golden round-trip tests validate the bound codec's +conversion between that JSON and the Dart domain object. The literal `kind` in +the server validator is the binding key, so every appearance of `drawing` uses +the `DrawingElement` codec and no endpoint may redefine it. If one endpoint +needs different semantics, the server declares a new tag. Icarus does not +duplicate every Flutter object field in TypeScript validators. + +Each generated query method returns a typed query object. Its `fetch()` method +returns `Future` and its `watch()` method returns `Stream`. The endpoint's +arguments and result type are declared once rather than duplicated across +separate fetch and watch declarations. The stream is cold and +single-subscription. Each listener opens one Convex subscription, and canceling +that listener closes it. Generated query objects do not cache or broadcast; +repositories must share a subscription explicitly when several consumers need +the same live result. `fetch()` and `watch()` are independent choices, not two +steps in one read. `fetch()` reads once. `watch()` emits the current result +first and then later changes, so a live consumer does not fetch before it +watches. + +Generated functions take typed named parameters instead of creating an +argument class for every endpoint. The generator serializes those parameters +inside the private implementation. + +The generated client groups functions by their Convex module, such as +`api.folders.listTree()` and `api.strategies.create(...)`, instead of placing +every function on one flat class. The spec path `folders:listTree` +deterministically becomes `api.folders.listTree()`. The generator has no +structural name override or last-write-wins rename table. If two Convex paths +convert to the same Dart identifier, or a path collides with the client API, +generation stops and Icarus renames the backend function while the server is +clay. + +The folder library uses one complete accessible-tree query. The existing +`folders:listAll` and `folders:listForParent` functions become +`folders:listTree`, and repository code shares its single subscription. Root +folders, immediate children, breadcrumbs, owned folders, and shared folders +are derived from that typed tree in Dart. Icarus already needs the complete +tree for its sidebar and path controls, while `listForParent` currently reads +that same tree before filtering it. Keeping both would pay for two reactive +queries without narrowing the server read set. + +The durable queue holds sealed typed op variants in memory. Hive stores each op +and its delivery state as a versioned map containing only primitive values. A +strict decoder reconstructs the sealed variant and reports an unreadable record +without exposing an untyped payload. Outbox encoding is separate from the +generated Convex request encoding, so a server contract change cannot silently +rewrite the on-disk queue format. + +Native and web share the same generated API, codecs, and exception mapping. +That layer targets one small raw transport interface. The native adapter uses +`convex_flutter`; the web adapter uses the Convex JavaScript client. + +Before generated codecs depend on either adapter, both transports normalize +their platform-specific results into one private Convex value model. Native +JSON strings and web JavaScript values never reach generated decoders +directly. A transport conformance suite feeds equivalent fixtures through both +adapters and requires identical normalized results for every supported Convex +value, including numeric boundaries, bytes, nested collections, optional +fields, and nulls. + +Typed object decoders ignore wire fields that their target Dart shape does not +declare, allowing a newer server to add data without breaking an installed +client. They still reject a missing declared field or a value of the wrong +type. + +Generated enums fail decoding when the server sends an unknown literal. Icarus +does not guess at new permission roles, sync statuses, or operation kinds. A +specific harmless enum may opt into an explicit `unknown` case. + +The cloud branch has no users, so protocol version 3 does not carry a converter +for pre-version-3 outbox records. The cutover clears only the branch-owned cloud +outbox and starts with `outboxRecordVersion: 2`; it never clears or rewrites the +local library or `.ica` data. `outboxRecordVersion`, `clientProtocolVersion`, +and each payload's `payloadVersion` remain separate counters. Each changes only +when its own format changes. + +Superseded in part (2026-09-27): cloud now has real users. A format change +carries a converter or a migration, and nothing ever clears an outbox. Protocol +version 4 (Paranoia rows at payload version 2) refuses older clients outright +and leaves their outbox records alone, to send once they reload. + +An unknown server error code becomes a `ConvexFunctionException` with an +`unknown` enum value while retaining the raw code and message. The affected +work pauses with that diagnostic; parsing the error never drops the op. + +Before changing the protocol, Icarus fixes queued-op merging so an op ID always +names immutable work. A retry of unchanged work keeps its ID, but any merge +that changes the intended operation receives a new ID. A regression test must +cover the dangerous sequence: Convex applies an op, its response is lost, the +client restores it, the user edits the same entity, and the next flush applies +that newer edit instead of treating it as a replay. This repair ships as a +separate prerequisite rather than hiding inside the protocol rewrite. + +Before wrapper generation begins, Icarus replaces the current generic sync-op +envelope with protocol version 3. The request and acknowledgement contracts +become discriminated unions whose variants declare only the fields they use. +Each request has one closed `type` literal that names exactly one legal +operation. The literal identifies both its entity and action; clients cannot +select those two parts independently and create an illegal pair. Its spelling +uses `.`, such as `element.patch`, because the separator keeps +both parts readable while the complete string remains one discriminator. +Protocol version 3 does not define `element.move` or `lineup.move`. The current +server executes those kinds exactly like `patch`, and the app does not emit +them. Pre-version-3 outbox records containing `move` disappear with the rest of +the development outbox; a distinct `move` returns only if Icarus gives it +distinct behavior. +Each variant also names the record whose revision it protects. It uses fields +such as `expectedPageRevision` and `expectedStrategyRevision` instead of a +generic `expectedRevision` whose meaning changes between operations. +Acknowledgements distinguish newly applied work from an idempotent replay. +An already-recorded op ID returns a first-class `noop` result rather than an +`applied` result carrying `noop` in a reason string. +The result union also separates expected `rejected` outcomes from unexpected +`failed` outcomes. A rejection carries a closed, generated reason that the +sync logic understands, such as a revision mismatch. A failure retains its +structured code and message and moves the affected work to visible attention; +the client never folds it into a rejection by lowercasing or parsing text. +When a revision mismatch causes rejection, that result includes a +variant-specific `current` snapshot containing the guarded record's revision +and typed value. Convex captures the snapshot in the rejecting transaction. +The client does not receive a generic `latestPayload` or issue a second fetch +that could observe a later state. +The redesign keeps the existing sync behavior: page-scoped delivery, +per-record revisions, durable queued work, client and op IDs for replay +protection, batched per-op outcomes, and no-op replay handling. Existing +pre-version-3 outbox records are cleared at the unreleased cutover. The +generator then reads this typed contract instead of preserving the loose +`entityType`, `kind`, optional payload, string reason, and untyped +latest-payload shape. + +The unreleased cloud switches directly to `clientProtocolVersion: 3`; Convex +does not keep a version 2 handler. The cutover wipes the clay deployment and +the development outbox, then rejects any older wire version with a structured +protocol-mismatch failure. + +After protocol version 3 settles and before generator work begins, every public +Convex function receives an explicit return validator. This is a separate +backend milestone because Convex enforces those validators at runtime. Tests +exercise each function against its new validator, and the app must remain green +before generated Dart starts depending on the completed return contract. diff --git a/docs/auth_flow_reference.md b/docs/auth_flow_reference.md new file mode 100644 index 00000000..addd96bc --- /dev/null +++ b/docs/auth_flow_reference.md @@ -0,0 +1,787 @@ +# Auth Flow Reference + +This document describes the current authentication flow in Icarus as it exists today. + +It is meant to answer four questions: + +1. What provider are we actually using? +2. How does Flutter authenticate with Supabase and then with Convex? +3. Why do both `Supabase` auth state and `users:ensureCurrentUser` exist? +4. Where should we look first if cloud auth starts failing? + +## High-Level Summary + +The app uses **Supabase Auth as the identity provider** and **Convex as the application backend**. + +The important detail is that Convex is **not** the primary login provider here. Convex trusts a **Supabase-issued JWT** through `convex/auth.config.ts`, and the Flutter app forwards that token to Convex using `ConvexClient.setAuthWithRefresh(...)`. + +That means the flow is: + +`User signs in -> Supabase gets a session -> Flutter extracts/refreshes the access token -> Convex validates the JWT -> Convex functions can read identity -> app ensures a local Convex users row exists -> cloud features are enabled` + +## Core Files + +These are the files that define the current behavior: + +- `lib/main.dart` +- `lib/config/cloud_build_config.dart` +- `lib/providers/auth_provider.dart` +- `lib/collab/convex_strategy_repository.dart` +- `lib/collab/generated/` +- `lib/collab/transport/` +- `lib/widgets/dialogs/auth/auth_dialog.dart` +- `convex/auth.config.ts` +- `convex/users.ts` +- `convex/lib/auth.ts` +- `convex/schema.ts` +- `lib/providers/library_workspace_provider.dart` +- `lib/providers/collab/cloud_collab_provider.dart` + +## 1. Startup: both clients are initialized up front + +At startup the app initializes the Convex client and then Supabase: + +```dart +final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, +); +await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: cloudBuildConfig.deploymentUrl, + clientId: cloudBuildConfig.clientId, + operationTimeout: const Duration(seconds: 30), + healthCheckQuery: defaultConvexHealthCheckQuery, + ), +); + +await Supabase.initialize( + url: 'https://gjdirtrtgnawqoruavqn.supabase.co', + anonKey: '...', + authOptions: const FlutterAuthClientOptions(detectSessionInUri: false), +); +``` + +Why this matters: + +- `ConvexClient.initialize(...)` creates the global Convex client used by the app. +- `CloudBuildConfig` selects the named development deployment for local, CI, + and prerelease builds. Stable and Store release scripts require an explicit + production URL and client ID. +- `Supabase.initialize(...)` sets up the auth provider that will issue JWTs. +- `detectSessionInUri: false` is intentional: the app handles OAuth callback URIs itself, on desktop and web alike, instead of relying on automatic URI parsing. Supabase's detector treats any URL with a `code` parameter as a sign-in (a `?code=` share link included), runs outside `authProvider`'s loading and error states, and on desktop would exchange the same `icarus://` link a second time alongside our handler. + +## 2. Convex trusts Supabase JWTs + +Convex is configured to accept a custom JWT provider: + +```ts +export default { + providers: [ + { + type: "customJwt", + applicationID: "authenticated", + issuer: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1", + jwks: "https://gjdirtrtgnawqoruavqn.supabase.co/auth/v1/.well-known/jwks.json", + algorithm: "ES256", + }, + ], +} satisfies AuthConfig; +``` + +This is the bridge between Supabase and Convex. + +Why it works: + +- Supabase signs access tokens. +- Convex validates those tokens against the configured `issuer`, `jwks`, and `algorithm`. +- If validation succeeds, `ctx.auth.getUserIdentity()` returns a non-null identity inside Convex functions. +- If this file is wrong, missing, or out of sync with Supabase, Convex will treat every request as unauthenticated. + +## 3. Flutter auth state is owned by `authProvider` + +The single source of truth on the client is `authProvider` in `lib/providers/auth_provider.dart`. + +It tracks: + +- Whether Supabase has a session +- Whether Convex auth has been configured successfully +- Whether a Convex-side incident is active +- Whether the app is safe to use cloud features + +The important state enum is: + +```dart +enum ConvexAuthStatus { + signedOut, + configuring, + ready, + incident, +} +``` + +This is intentionally more specific than just "logged in / logged out". + +Why this exists: + +- A Supabase session by itself is not enough. +- The app only enables cloud mode when Convex has also accepted the token and the app-level user row exists. + +## 4. Login entry points + +### Email/password + +The dialog calls: + +- `signInWithEmailPassword(...)` +- `signUpWithEmailPassword(...)` + +Those methods authenticate directly with Supabase. + +### Discord OAuth + +Discord login uses Supabase OAuth. The redirect depends on the platform +(`currentAuthRedirectUri()` in `auth_provider.dart`, built from +`lib/services/auth_callback_uri.dart`): + +```dart +final launched = await _supabaseApi.signInWithOAuth( + OAuthProvider.discord, + redirectTo: currentAuthRedirectUri().toString(), + authScreenLaunchMode: LaunchMode.externalApplication, + scopes: 'identify email', +); +``` + +- Desktop: `icarus://auth/callback`, a custom deep link. The browser opens + externally and the OS hands the link back to the app. +- Web: the root of the page's own origin, e.g. `https://beta.icarusstrats.com/`. + The tab itself navigates to Discord and back. The root (not the current path) + means one Supabase allowlist entry per origin. + +Why this works: + +- Supabase handles the OAuth exchange with Discord. +- On success, Supabase redirects back to the redirect URI with a PKCE `?code=`. +- The app hands that URI to Supabase to finalize the session. The PKCE code + verifier is saved in local storage when sign-in starts, so on web the return + must land on the same origin that started it. + +Every redirect Supabase is asked for must be in the project's redirect +allowlist (Authentication > URL Configuration). A redirect outside it silently +falls back to the Site URL, which on web means a different origin and a failed +code exchange. The allowlist holds: + +- `icarus://auth/callback` (desktop) +- `https://beta.icarusstrats.com/` (web beta) +- `https://icarus-web-a50.pages.dev/` (the Cloudflare Pages host of the beta) +- `http://localhost:*/` (local `flutter run -d chrome`) + +Because the web sign-in leaves the page, anything held only in memory is lost. +A share code opened while signed out waits in the tab's sessionStorage +(`PendingShareCodeStore`) and is redeemed once the returning session is ready. + +## 5. OAuth callback handling + +The app bootstraps `authProvider` immediately and routes deep links into it: + +```dart +ref.read(authProvider); + +unawaited( + ref.read(authProvider.notifier).handleAuthCallbackUri(uri, source: source), +); +``` + +Where the URI comes from: + +- Desktop: `app_links` (initial link and stream) and second-instance arguments. +- Web: the page's own URL (`Uri.base`), published once at startup when it is + an auth callback or a share link. `app_links` is not used on web; its web + plugin only echoes the page URL. + +The provider classifies the incoming URI with +`classifyAuthCallbackUri(uri, redirectUri: currentAuthRedirectUri())`. Only a +URI that lands on this build's redirect URI (same scheme, host, port, and path) +counts; requiring the redirect's path keeps a `/share?code=…` link from being +mistaken for a sign-in. Then: + +- A PKCE `?code=` or an error is a sign-in result and goes to Supabase. A + forged code is harmless: it only exchanges with the verifier this device + saved when it started sign-in. +- Session tokens (`access_token`, `refresh_token`, `provider_token`, …) in the + query or fragment are rejected, on desktop and web alike. We only use the + PKCE flow, so Supabase never sends them, but anyone can craft such a link, + and GoTrue's `getSessionFromUrl` would import them and sign the user into + the attacker's account. The link is logged (redacted) as rejected, never + handed to Supabase, and on web scrubbed from the address bar. + +Deep links are logged through `redactDeepLinkUri`, which hides sign-in secrets +and share codes (the `/share/` path, `?code=`, `?token=`): a share code +is a credential. + +Then it completes the Supabase session: + +```dart +await _supabaseApi.getSessionFromUrl(uri); +``` + +On web, `main.dart` then removes the sign-in parameters from the address bar +(`withoutAuthCallbackParameters` + `replaceBrowserUrl`), so a refresh does not +replay a spent code. + +Why this matters: + +- OAuth relies on the callback URI reaching the handler. +- If the callback never reaches `handleAuthCallbackUri(...)`, the browser may show a successful login while the app stays signed out. + +## 6. Supabase auth changes trigger Convex auth setup + +When `authProvider` builds, it subscribes to the Supabase auth stream: + +```dart +_supabaseAuthSub ??= _supabaseApi.onAuthStateChange.listen( + _handleSupabaseAuthStateChange, + onError: _handleSupabaseAuthStreamError, +); +``` + +Every auth state change immediately resets Convex readiness and reruns the bridge setup: + +```dart +state = AppAuthState.fromSession( + currentSession, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: currentSession == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, +); + +unawaited( + _configureConvexAuth( + trigger: 'supabase:${event.event}', + generation: generation, + sessionFingerprint: _sessionFingerprint(currentSession), + ), +); +``` + +Why this is important: + +- Supabase is the source of identity. +- Convex auth must be re-bound whenever the session changes, refreshes, or disappears. + +One event is not a change: Supabase replays the session it restored at +startup to every new listener as `initialSession`. `build()` has already +started setup for that session, so an `initialSession` carrying the same +session fingerprint is ignored. A second setup would dispose the first one's +Convex auth under the library's live queries. (A different session, say one +Supabase refreshed while restoring, still gets its own setup.) + +## 7. The client forwards the Supabase token to Convex + +This is the most important bridge in the whole system. + +The provider configures the Convex client like this: + +```dart +final authHandle = await _convexApi.setAuthWithRefresh( + fetchToken: _fetchSupabaseAccessToken, + onAuthChange: (isAuthenticated) { + if (!isAuthenticated && _supabaseApi.currentSession != null) { + unawaited( + reportConvexUnauthenticated( + source: 'convex:onAuthChange', + error: Exception('Convex auth state changed to unauthenticated'), + ), + ); + } + }, +); +``` + +The token supplier is: + +```dart +Future _fetchSupabaseAccessToken() async { + final session = _supabaseApi.currentSession; + if (session == null) return null; + + final expiresAt = session.expiresAt; + if (expiresAt != null) { + final expiresAtUtc = DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + isUtc: true, + ); + final shouldRefresh = expiresAtUtc + .isBefore(DateTime.now().toUtc().add(const Duration(minutes: 1))); + + if (shouldRefresh) { + final refreshed = await _supabaseApi.refreshSession(); + final refreshedToken = refreshed.session?.accessToken; + if (refreshedToken != null && refreshedToken.isNotEmpty) { + return refreshedToken; + } + } + } + + return session.accessToken; +} +``` + +Why this works: + +- Convex does not know how to log the user in to Supabase. +- Instead, Convex asks the Flutter client for a valid token whenever it needs one. +- The provider refreshes the Supabase session if the token is about to expire. +- That keeps long-lived desktop sessions working without forcing the user to log in again constantly. + +## 8. Convex auth is not considered ready until the connection authenticates + +After setting the auth handler, the provider explicitly reconnects the Convex client and waits for it to become authenticated: + +```dart +reconnectResult = await _convexApi.reconnect(); +final readinessSource = await _waitForConvexAuthenticated( + trigger: trigger, + reconnectResult: reconnectResult, +); +``` + +And `_waitForConvexAuthenticated(...)` blocks until either: + +- the client is already authenticated, or +- `authState` emits `true`, or +- a timeout occurs + +Why this exists: + +- A Supabase session can exist before the Convex websocket/query layer has fully reconnected with the new token. +- Without this wait, the app could immediately fire protected Convex queries and get intermittent unauthenticated errors. + +## 9. The app provisions a Convex `users` row after auth is ready + +Once Convex says the token is accepted, the app immediately runs the named +auth boundary: + +```dart +await _convexApi.ensureCurrentUser(); +``` + +The default auth adapter delegates that method to +`ConvexStrategyRepository.ensureCurrentUser()`. Inside `lib/collab/`, the +repository calls the generated `api.users.ensureCurrentUser()` method. Auth +code does not spell a route or construct a raw argument map. + +That mutation does this: + +```ts +export const ensureCurrentUser = mutation({ + args: {}, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const externalId = getCanonicalExternalId(identity); + const displayName = identity.name ?? identity.nickname ?? "Discord user"; + const avatarUrl = identity.pictureUrl ?? undefined; + + const existingUser = await findUserByIdentity(ctx, identity); + + if (existingUser !== null) { + await ctx.db.patch(existingUser._id, { + externalId, + displayName, + avatarUrl, + updatedAt: Date.now(), + }); + return { ok: true as const }; + } + + await ctx.db.insert("users", { + externalId, + displayName, + avatarUrl, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + return { ok: true as const }; + }, +}); +``` + +Why this exists: + +- Convex auth gives us an authenticated identity, but that identity is not the same thing as an app-level `users` table row. +- The rest of the data model uses `users._id` as a foreign key for folders, strategies, collaborators, invites, and images. +- So the app must materialize "the current authenticated identity" into a stable row in `users`. + +## 10. Identity mapping uses `tokenIdentifier` as the canonical key + +The backend auth helper makes this explicit: + +```ts +export function getCanonicalExternalId(identity: { + tokenIdentifier: string; +}): string { + return identity.tokenIdentifier; +} +``` + +It also supports a fallback for older records: + +```ts +export function getLegacyExternalId(identity: { + subject?: string | null; + tokenIdentifier: string; +}): string | null { + const subject = identity.subject; + if (subject == null || subject == identity.tokenIdentifier) { + return null; + } + return subject; +} +``` + +Then user lookup tries the canonical value first and the legacy value second. + +Why this matters: + +- `tokenIdentifier` is the current stable identity key used by Convex auth. +- The legacy fallback strongly suggests the app used `subject` at some point and later migrated. +- This is a compatibility layer so older `users.externalId` values do not strand existing accounts. + +## 11. Protected Convex functions always derive the user server-side + +Protected functions never trust a client-supplied `userId`. + +They call: + +```ts +export async function requireCurrentUser(ctx: AnyCtx): Promise> { + const identity = await ctx.auth.getUserIdentity(); + if (identity === null) { + throw unauthenticatedError(); + } + + const user = await findUserByIdentity(ctx, identity); + if (user === null) { + throw new Error( + "Missing user record. Call users:ensureCurrentUser before querying collaborative data.", + ); + } + + return user; +} +``` + +And for strategy-level access: + +```ts +export async function assertStrategyRole( + ctx: AnyCtx, + strategy: Doc<"strategies">, + required: StrategyRole, +): Promise<{ user: Doc<"users">; role: StrategyRole }> { + const user = await requireCurrentUser(ctx); + const role = await getStrategyRoleForUser(ctx, strategy, user._id); + + if (!hasRole(role, required)) { + throw new Error("Forbidden"); + } + + return { user, role: role as StrategyRole }; +} +``` + +Why this works: + +- The client cannot impersonate another user by sending a fake ID. +- Ownership and collaboration checks are done against the authenticated identity that Convex extracted from the JWT. + +## 12. App data is linked to the Convex user row + +The schema makes that explicit: + +```ts +users: defineTable({ + externalId: v.string(), + displayName: v.string(), + avatarUrl: v.optional(v.string()), + createdAt: v.number(), + updatedAt: v.number(), +}).index("by_externalId", ["externalId"]), +``` + +And other tables point to `users._id`: + +- `folders.ownerId` +- `strategies.ownerId` +- `strategyCollaborators.userId` +- `inviteTokens.createdByUserId` +- `imageAssets.createdByUserId` + +So auth is not just "can this request run?". +It is also "which app-level user owns this data?". + +## 13. Cloud mode is gated on both auth layers + +The app does **not** consider cloud mode available when only Supabase is signed in. + +It requires both: + +- `isAuthenticated == true` +- `isConvexUserReady == true` + +That gate is here: + +```dart +final isCloudWorkspaceAvailableProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return auth.isAuthenticated && auth.isConvexUserReady; +}); +``` + +And again here: + +```dart +return featureFlagEnabled && + isAuthenticated && + isConvexUserReady && + !forceLocalFallback; +``` + +Why this matters: + +- It prevents the UI from exposing cloud functionality during the gap between "Supabase has a session" and "Convex has accepted the token and provisioned a user". +- That is a major reason the current flow is more stable than a simple boolean login flag. + +## 14. Real cloud queries and mutations depend on this contract + +The repository is the only application boundary that sees generated structural +types. It calls the typed module and maps the result to the Icarus folder model: + +```dart +return _api.folders + .listTree( + scope: const ConvexOptional.present(FoldersListTreeArgsScope.all), + ) + .watch() + .map((folders) => folders.map(_folderEntry).toList(growable: false)); +``` + +Backend functions enforce auth immediately: + +```ts +export const listTree = query({ + args: { scope: folderScopeValidator }, + returns: v.array(folderSummaryValidator), + handler: async (ctx, args) => { + const user = await requireCurrentUser(ctx); + // ... + }, +}); +``` + +And collaborative editing requires role checks: + +```ts +export const applyBatch = mutation({ + args: { + strategyPublicId: v.string(), + clientId: v.string(), + clientProtocolVersion: v.literal(3), + ops: v.array(strategyOpValidator), + }, + returns: applyBatchResultValidator, + handler: async (ctx, args) => { + let strategy = await getStrategyByPublicId(ctx, args.strategyPublicId); + await assertStrategyRole(ctx, strategy, "editor"); + // ... + }, +}); +``` + +So if auth fails anywhere upstream, these functions are where the break becomes visible. + +## 15. How unauthenticated incidents are detected + +The system has a special "Supabase says signed in, but Convex says unauthenticated" path. + +Convex returns a structured error: + +```ts +export function unauthenticatedError(): ConvexError<{ + code: 'UNAUTHENTICATED'; + message: string; +}> { + return new ConvexError({ + code: 'UNAUTHENTICATED', + message: 'Unauthenticated', + }); +} +``` + +The transport preserves that data as a structured error. The generated client +maps the raw code to `ConvexErrorCode`, and the collaboration boundary checks +the typed values without parsing the message: + +```dart +bool isTypedConvexUnauthenticatedError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.unauthenticated) || + (error is ConvexClientFunctionError && + error.rawCode == ConvexErrorCode.unauthenticated.wireName); +} +``` + +When that happens, the provider enters `incident` state and can prompt the user to: + +- retry Convex auth +- sign out +- dismiss and keep cloud paused + +Why this exists: + +- Desktop sessions can drift out of sync. +- Supabase may still think the user is signed in while Convex no longer accepts the token or has lost the authenticated connection. +- This gives the app a controlled recovery path instead of repeated silent failures. + +## 16. Why the race-protection code exists + +`authProvider` contains several safeguards that are easy to overlook but are very important: + +- `_authGeneration` +- `_sessionFingerprint(...)` +- `_isAuthContextCurrent(...)` +- `_inFlightConvexSetup` +- `_queuedConvexSetup` + +These exist because auth setup is asynchronous and can be triggered many times: + +- initial app startup +- Supabase sign-in +- OAuth callback completion +- session refresh +- manual retry +- sign-out + +Without these guards, a stale setup task could finish late and overwrite state for a newer session. + +In other words, this code is preventing classic auth race conditions. + +## 17. Why this flow works overall + +The flow is stable because each layer has a single responsibility: + +- **Supabase** proves who the user is and issues JWTs. +- **Flutter authProvider** owns session lifecycle, token refresh, deep link handling, and bridge state. +- **Convex auth config** teaches Convex how to verify Supabase JWTs. +- **`users:ensureCurrentUser`** converts external identity into an app-level `users` row. +- **`requireCurrentUser` / `assertStrategyRole`** protect actual business data and collaboration rules. +- **Cloud feature gates** stop the UI from using Convex too early. +- **Incident handling** gives recovery behavior when Supabase and Convex drift apart. + +That separation is the main reason the system is understandable and debuggable. + +## 18. Failure points and what they usually mean + +### Symptom: Supabase login succeeds but Convex queries return unauthenticated + +Likely causes: + +- `convex/auth.config.ts` no longer matches the Supabase issuer or JWKS +- the access token is not being forwarded to Convex +- token refresh failed and an expired token is being reused +- Convex connection did not fully re-authenticate after session change + +First places to inspect: + +- `convex/auth.config.ts` +- `_fetchSupabaseAccessToken()` +- `_runConvexAuthSetup(...)` +- `reportConvexUnauthenticated(...)` + +### Symptom: user is authenticated but gets "Missing user record" + +Likely causes: + +- `users:ensureCurrentUser` never ran +- it ran before Convex auth was actually ready +- identity mapping changed and `findUserByIdentity(...)` can no longer find the row +- the `users` row was deleted or corrupted + +First places to inspect: + +- `users:ensureCurrentUser` +- `findUserByIdentity(...)` +- `users.externalId` +- `getCanonicalExternalId(...)` +- `getLegacyExternalId(...)` + +### Symptom: Discord browser flow completes but app never signs in + +Likely causes: + +- the `icarus://auth/callback` deep link is not reaching the app +- web: the page origin is not in the Supabase redirect allowlist, so Supabase + returned to the Site URL (another origin, without the PKCE code verifier) +- the callback URI shape changed +- `getSessionFromUrl(...)` is failing +- duplicate-link filtering or platform URI handling is dropping the callback + +First places to inspect: + +- `signInWithDiscord()` +- `main.dart` deep link handling +- `handleAuthCallbackUri(...)` +- `classifyAuthCallbackUri(...)` + +### Symptom: signed-in user cannot access a strategy + +Likely causes: + +- the user exists, but has no owner/collaborator mapping for that strategy +- collaborator rows are missing or wrong +- backend is correctly returning `Forbidden` + +First places to inspect: + +- `assertStrategyRole(...)` +- `getStrategyRoleForUser(...)` +- `strategyCollaborators` +- `strategies.ownerId` + +## 19. Practical debug checklist + +If auth breaks, verify these in order: + +1. Does Supabase have a non-null current session? +2. Is `_fetchSupabaseAccessToken()` returning a real token? +3. Does `ConvexClient` transition to authenticated after `setAuthWithRefresh(...)` and `reconnect()`? +4. Does `ctx.auth.getUserIdentity()` return a value inside Convex? +5. Does `users:ensureCurrentUser` succeed? +6. Does `users.me` return the expected app user? +7. Does `requireCurrentUser(...)` resolve that user inside protected functions? +8. If the request is strategy-scoped, does `assertStrategyRole(...)` return the expected role? + +If one of these steps fails, the break is usually in that layer or the layer immediately before it. + +A failed Convex setup is reported through `AppErrorReporter` as +`Convex auth setup failed []: ` (or `readiness timed +out`). It lands in the in-app debug log, and on web it is also printed to the +browser console: `developer.log` does not reach the console in a release web +build, so the console is the first place to look on the web beta. + +## 20. Mental model to keep in mind + +The most useful mental model is: + +- **Supabase session** means "the user has logged in". +- **Convex authenticated connection** means "Convex trusts the Supabase JWT". +- **Convex user row exists** means "the application can attach ownership and permissions to this identity". +- **Cloud enabled** means "all three conditions are true enough for the UI to rely on cloud state". + +That distinction is the key to understanding the current system. diff --git a/docs/cloud_online_release_gaps.md b/docs/cloud_online_release_gaps.md new file mode 100644 index 00000000..7dcc4c34 --- /dev/null +++ b/docs/cloud_online_release_gaps.md @@ -0,0 +1,253 @@ +# Icarus Online Beta Readiness + +Last refreshed: 2026-08-30 + +This is the release gate for the first invite-only Icarus Online beta. It is a +checklist, not a backlog. A checked item needs current evidence from the build +being released. + +## Decision Today + +The current release candidate is ready for internal testing, but do not invite +outside testers yet. + +The transport, authorization, durable outbox, native restart, R2 server, and +Windows local-library lifecycle boundaries now have evidence. The remaining +blocker is one visible two-client run containing a real UI write, offline +restart, rejection, media round trip, sign-out, and sign-in. Automated tests +prove those state machines, but they do not prove that every on-screen state is +legible in the built app. + +## 2026-08-30 Release-Candidate Receipt + +This receipt belongs to the release-candidate branch cut from +`icarus-cloud` at `ce9beec189e35f48cced623e161aa55f2642e782`. +Freeze the shippable SHA to the pull-request head after every required check is +green. On pull requests every CI job explicitly checks out that source SHA, +instead of GitHub's temporary merge commit, and the Windows job includes it in +the artifact name. + +Completed against the current candidate: + +- `npx tsc --noEmit` passes. +- `npm run test:convex` passes 30 tests, including three A/B/C access-boundary + tests and durable delete cleanup for collaborators and share links. +- The scrubbed Convex contract snapshot is current. The strict audit passes 42 + public functions and 34 error codes. +- `fvm flutter test` passes 388 tests with one expected Windows probe skip. The + suite covers persistence before send, in-flight restart replay, retry + exhaustion, manual retry identity, rejected-op reconciliation, auth setup + incidents, and local fallback when cloud becomes unavailable. +- `fvm flutter analyze --no-fatal-infos` has no errors and six existing info + notices. +- The web release build and the macOS release build complete. macOS requires + `--no-tree-shake-icons`, matching the existing CI builds. +- Two fresh Supabase users completed real password authentication and + `users.ensureCurrentUser` against the deployed Convex development backend. +- A deployed A/B/C contract run proved private isolation, viewer read-only, + editor mutation and readback, revoked-link denial for a new user, durable + redeemed access for an existing collaborator, and inherited folder roles. + The third identity was synthetic because Supabase rate-limited creation of a + third disposable email account. +- A live R2 run proved presigned upload, public byte readback, rejection of a + tampered object key, rejection of completion for a missing object, and test + cleanup. This does not yet prove the full built-app media queue. +- Computer Use verified an isolated macOS 4.3.7 release app with a unique bundle + identifier. Persisted auth reconnected after a full process restart, a cloud + Strategy appeared through the live subscription, a server rename arrived + without reload, and the Strategy opened in the editor with **Synced** visible. + The isolated app did not touch the normal Icarus application-support path. +- The Windows CI gate now installs public 3.2.3 and imports the real v35 + `base-test.ica` fixture pinned to repository commit `fddec2b0`. It verifies + the GitHub release asset digest and fixture SHA before executing either + input, then requires a responsive app window and a non-empty Strategy library. + A read-only probe compares stable Strategy, page, and entity identities and + ordering, then normalizes the public library through the current migration + path and compares its complete semantic fingerprint after the candidate + upgrade and after rollback. The rollback must also leave the candidate Hive + bytes untouched. The installer and JSON receipt are uploaded together. +- Run `33296220156` passed that gate for source + `57f4acb26f13ef4c47773f750949b11b68e7a272`. Its private artifact, + `icarus-windows-rc-57f4acb26f13ef4c47773f750949b11b68e7a272`, recorded + public 3.2.3+42, candidate 4.3.7+91, then rollback 3.2.3+42. All three + libraries were 3,449 bytes with SHA-256 + `7e27b9594478c9aa85a550944d58d95c415875a13ac010f7bf5d3777c003e133`; + the semantic, rollback, and untouched-candidate-byte invariants all passed. + This artifact is private CI evidence, not a published Windows release. + +Known verification limitations: + +- Computer Use could focus the macOS Flutter name field, but native keyboard + and clipboard injection did not reach Flutter's text controller. The live + Strategy was therefore created through the authenticated backend before its + subscription, restart, update, and editor readback were verified visually. +- The collaborative browser could serve and navigate the release web build, + but Flutter's semantics snapshot timed out. The earlier 2026-08-25 real web + sign-in and write/readback receipt remains the current visible web proof. +- R2 upload jobs are reconstructed from local page media after restart rather + than persisted as an independent durable job table. The built-app + upload/restart/download/export path remains unchecked below. + +## Release Rule + +The beta is ready when every P0 item is checked against one release candidate, +there are no unresolved data-loss or access-control findings, and the tester +can understand every non-synced state without opening logs. + +P1 items should be complete before inviting people who will not have a direct +support channel. P2 items can follow the invite-only beta. + +## P0: Protect the Library + +- [x] The Convex client and deployed server complete authentication without a + fatal protocol error or reconnect loop. + - Evidence: production web build verified on 2026-08-25 with real sign-in, + authenticated strategy create, second-page edit to **Synced**, reload, + fresh sign-in, server readback of both pages, and test-data cleanup. No new + protocol error or reconnect loop appeared in the post-fix console. +- [x] A local-mode library created on the current public build opens unchanged + after installing the beta. + - Evidence: Windows run `33296220156` installed public 3.2.3, imported the + pinned v35 `base-test.ica`, and upgraded to source `57f4acb2`. The public + and candidate Strategy identities, normalized semantics, 3,449-byte Hive + file, and full-library SHA-256 were identical. +- [ ] Signing in does not move, delete, or rewrite local strategies unless the + user explicitly starts a migration. + - Evidence: pending; compare library counts and exported fixtures before and + after sign-in. +- [ ] A cloud strategy survives create, edit, app restart, sign-out, and sign-in + with pages, drawings, agents, abilities, lineups, media, and ordering + intact. + - Evidence: pending; attach the exported `.ica` before and after the cycle. +- [ ] Exporting then importing both a local strategy and a cloud strategy loses + no supported data. + - Evidence: pending; compare canonical exports, allowing only documented + identity fields to differ. +- [ ] An offline edit remains visibly pending, lands after reconnect, and still + lands after the app is restarted while offline. + - Evidence: durable outbox tests pass the restart-before-send and + restart-in-flight boundaries. The three visible status receipts are still + required. +- [ ] A rejected or exhausted op never produces a **Synced** state. The user + sees an actionable error before leaving the strategy. + - Evidence: retry-exhaustion and rejected-op provider tests pass and preserve + immutable operation identity. A built-app rejection receipt is still + required. + +## P0: Prove the Online Loop + +- [ ] Account A can create a cloud strategy and Account B cannot see it before + it is shared. +- [ ] Account A can create a view-only link. Account B can join and view every + page but cannot mutate the strategy. +- [ ] Account A can create an editor link. An edit from Account B appears for + Account A without either client reloading or losing local input. +- [ ] Simultaneous edits take the documented conflict path. No accepted edit is + silently overwritten, and uncertainty is visible. +- [ ] Disabling a share link blocks a new Account C from joining while Account + B keeps the access already granted. +- [ ] Removing or downgrading an existing collaborator, if exposed in this + beta, changes their effective access on the next protected operation. +- [ ] Shared folders apply the correct effective role to their strategies, + including inherited editor access. + +Contract evidence for this section is green in `convex/accessControl.test.ts` +and in the deployed development backend. Product evidence still requires one +screen recording with two clean browser profiles or two installed clients, +plus the release commit and Convex deployment name. + +## P0: Platform and Failure Checks + +- [ ] macOS release build completes the full local and cloud smoke path. +- [ ] Windows release build completes the full local and cloud smoke path. + - Evidence: the public-install, candidate-upgrade, and rollback local-library + path is green in run `33296220156`; the visible cloud path is still pending. +- [ ] Web release build completes the authenticated second-client smoke path. +- [ ] Expired auth, revoked auth, unavailable Convex, and unavailable media + storage each produce an honest on-screen state and recover without an app + restart. +- [ ] Cloud media upload, retry, restart recovery, download, export, and cleanup + are verified against the configured R2 bucket and public domain. +- [x] A rollback build can still open the untouched local library. If cloud data + cannot be read by the rollback build, the release notes say so plainly. + - Evidence: run `33296220156` reinstalled public 3.2.3 after the candidate, + opened the same Strategy, matched its semantic fingerprint, and left the + candidate-written Hive bytes unchanged. Cloud rollback remains unsupported + and must be stated before any public beta release. + +## P1: Make the Beta Legible + +- [ ] Signed-out Cloud and Shared destinations lead to one obvious login action. +- [ ] Empty Cloud has a create-strategy action; empty Shared has an add-by-link + or code action. +- [ ] Auth, library navigation, sharing, sync status, and recovery controls have + stable semantics and automation keys. +- [ ] Share copy says **Disable link**, not delete or remove access, and explains + that existing collaborators keep access. +- [ ] The web build identifies itself as beta and points desktop-primary users + to a stable installer. +- [ ] Every beta tester has a visible way to report a problem with app version, + platform, and sync state attached. + +## P2: After the Invite-Only Beta Starts + +- [ ] Decide whether share-link expiration belongs in the product. +- [ ] Add collaborator management if testers need remove/downgrade controls. +- [ ] Automate stale media cleanup and document its retention window. +- [ ] Add operational alerts for repeated protocol failures, rejected ops, and + media jobs that exhaust retries. +- [ ] Define free storage and usage limits from observed beta use instead of + guessing before there is real usage data. + +## Automated Gate + +Run from the repository root on the exact release commit: + +```sh +npx tsc --noEmit +npm run test:convex +npm run snapshot:convex-contract:check +npm run audit:convex-contract +fvm flutter analyze --no-fatal-infos +fvm flutter test +fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development +fvm flutter build macos --no-tree-shake-icons --dart-define=ICARUS_CLOUD_ENVIRONMENT=development +``` + +On the pull request, CI also builds the Windows installer, runs +`scripts/test_online_beta_windows_upgrade.ps1`, and uploads the installer plus +its evidence JSON under an artifact name containing the exact source-branch +commit SHA. Every CI job explicitly checks out that same SHA. + +Expected on 2026-08-29: no analyzer errors, six existing info notices, 387 +Flutter tests green, 30 Convex tests green, TypeScript green, the contract audit +green, and release builds green. These checks are necessary, but they do not +replace the visible online-loop proof. + +## Computer-Use Verification Path + +Use clean profiles so cached auth and local Hive data cannot make the test pass +by accident. + +1. Start Client A signed out. Confirm the local library and `.ica` import still + work before touching online mode. +2. Sign in as Account A. Create a cloud strategy with at least two pages, one + drawing, one agent, one ability, one lineup, and one image. +3. Open Client B in a separate clean profile and sign in as Account B. Confirm + the strategy is absent before sharing. +4. Share view-only, join from B, and attempt each visible mutation. No mutation + may reach the server. +5. Share editor, edit from B, and watch A. Repeat in the other direction while + both clients have unsaved local input. +6. Take B offline, edit, restart B, reconnect, and wait for the pending op to + land. Verify both clients and the sync-status surface agree. +7. Disable the link, then attempt a fresh join from clean Account C. Confirm B + retains existing access. +8. Restart both clients, export the strategy, import it locally, and compare the + result with the cloud copy. +9. Save the recording, release commit, deployment name, platform versions, and + any console/server errors next to the checked items above. + +Stop immediately on silent data loss, a false **Synced** state, an access-control +bypass, or a protocol reconnect loop. Preserve the clients and server state for +diagnosis instead of continuing the script. diff --git a/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_handoff.html b/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_handoff.html new file mode 100644 index 00000000..8e1ee822 --- /dev/null +++ b/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_handoff.html @@ -0,0 +1,767 @@ + + + + + + + + + Rerun the Dart client gauntlet with the missing control + + + + + + + +
+
+
+ + ◆ HANDOFF + +
+
+
+ + + +
+ + +
+
+

Icarus cloud · Ready to rerun · Decision reopened

+

Rerun the Dart client gauntlet with the missing control

+

The first gate found a real Dartvex strictness gap, but it did not settle Dartvex versus convex_flutter. Its result-field leg regenerated the same fixture, whose return contract was null, instead of testing an actual result rename. A diagnostic control with an explicit return schema did catch the rename at analysis time. The fair next step is to make the server contract explicit, add a thin fail-closed Icarus wrapper, and then run the same runtime faults against both clients.

+ +
+

Decision now

+ +
+
1missing result mutation
+
0runtime seeds completed
+
3analyzer exit on real rename
+
50rerun seeds required
+
+ +

No client winner yet. Keep the first gate as evidence that Dartvex 0.2.0 is not fail-closed by default. Reopen the runtime decision because neither client has completed the symmetric chaos test.

+ +

The goal is still less JSON plumbing and stronger generated Dart APIs. The first run shows that Dartvex can supply useful function and argument types, but Icarus must enforce complete public return validators and reject degraded output. That compensation is small enough to test before writing a generator or forking another package.

+
+ +
+

What the first gate proved

+ +
+ + + + + + + + + + + +
CheckObservedMeaningStatus
Function renameOld method failed analysis with exit 3Generated function names protect callersproved
Argument renameOld named argument failed analysis with exit 3Generated arguments protect callersproved
Result-field renameBaseline fixture regenerated; returns stayed nullNo result mutation was exercisedinvalid leg
Missing return schemaDartvex emitted Future<dynamic>Unspecified server output cannot become a typed Dart resultreal gap
Unknown validatorWarning, exit 0, field degraded to dynamicDefault generation is not fail-closedreal gap
DeterminismSecond baseline generation produced no diffSame input generated the same outputproved
Runtime chaos and profileSkipped by the original stop ruleNo correctness, latency, memory, reconnect, or auth comparison existsnot run
+
+

Evidence: contract_gate.dart, baseline function spec, contract_gate.json, and the first result note.

+ +

The exact fairness failure

+

In contract_gate.dart, the result-field leg calls generate('folders_list_for_parent.json') again. The fixture contains "returns": null. The real folders:listForParent query also has no explicit returns: validator. The old caller therefore remained valid because there was no typed result contract to rename.

+ +

A diagnostic control supplied an explicit object return, generated the typed caller, then changed publicId to folderPublicId. Baseline analysis exited 0; analysis against the renamed result exited 3 with an undefined getter. That does not make Dartvex the winner. It proves the missing control can reverse the narrow conclusion about result-drift detection.

+
+ +
+

The neutral test boundary

+ + + +

Icarus continues to own the outbox, clientId/opId identity, revision and conflict rules, canonical JSON, and .ica round-trip. A client package is transport and tooling, not the owner of those promises. This is also why adopting either package unchanged would be the wrong abstraction boundary.

+

Before touching server sync boundaries, read server_side_sync_boundaries_handoff.md.

+
+ +
+

Fair rerun plan

+ +

Phase 1: repair the contract gate

+
    +
  1. Add explicit returns: validators to the stable public Convex functions used by the comparison. Match the real payload exactly; do not create a test-only fantasy type.
  2. +
  3. Regenerate a scrubbed baseline fixture and add a separate result-renamed fixture where publicId becomes folderPublicId.
  4. +
  5. Compile the unchanged caller against both. Baseline must exit 0. The renamed fixture must exit nonzero and identify the old getter.
  6. +
  7. Add an Icarus-owned wrapper around Dartvex generation. It fails if generation logs Warning:, if a stable public module contains unexpected dynamic, or if the generator exits nonzero.
  8. +
  9. Keep the unknown-validator mutation. It must fail the wrapper with the function and field path.
  10. +
  11. Run generation twice and require a clean repository diff.
  12. +
+

Boundary: do not fork Dartvex or write a replacement generator in this phase. First prove whether explicit server schemas plus a thin strict wrapper deliver the typed API we want.

+ +

Phase 2: make the runtime comparison symmetric

+

Define one small typed Icarus transport interface for the operations used by the gauntlet. Implement it once with Dartvex and once with convex_flutter. Both adapters must receive the same serialized operations, tokens, reconnect schedule, timeouts, and deployment. Package-specific convenience APIs cannot change the workload.

+

Keep two scorecards. The tooling score covers generated coverage, compile-time mutation catches, determinism, warnings, diff size, and maintenance. The runtime score covers correctness, convergence, recovery, latency, CPU, memory, and platform builds. A tooling loss cannot masquerade as a runtime loss, and a fast runtime cannot excuse corrupted library state.

+ +

Phase 3: run correctness before performance

+
    +
  • Run 50 deterministic seeds × 1,000 operations for each adapter.
  • +
  • Use editors A and B plus a clean verifier C. Begin every seed from base-test-v43.ica.
  • +
  • Exercise offline edits, delayed and duplicated delivery, reconnect, subscription restart, delete/recreate, revision conflict, and bounded retries.
  • +
  • Exercise an expired or rejected access token, call the current Supabase Flutter refreshSession() path, reconnect, and prove the queued op lands exactly once.
  • +
  • Persist the runner ledger and reuse identical clientId/opId values for both adapters so a process restart does not give one client an easier test.
  • +
  • After every seed, verifier C exports canonical state. Compare strategies, pages, folders, lineups, order, revisions, and round-trip output. Never compare timestamps or transport-only metadata that the product does not promise.
  • +
+

Use disposable test accounts and publishable client credentials. Never put a Supabase secret or service_role key in Flutter, fixtures, committed output, or logs. The current Supabase Dart API documents refreshSession() as refreshing and returning a new session even when the current session is not expired; the fault injector should assert the session actually changes or is accepted before replaying the op.

+ +

Phase 4: profile only after both are correct

+

Run at least 10 paired profile-build trials, alternating which adapter runs first. Report median and p95 remote convergence, reconnect-to-live time, peak RSS, steady-state CPU, transferred bytes, and build size on every supported desktop target. Record raw samples, tool versions, machine state, commit, and deployment identity.

+
+ +
+

What settles the gauntlet

+ +
+ + + + + + + + + +
ConditionDartvex consequenceconvex_flutter consequence
Any dropped, duplicated, misordered, or silently conflicted library changeImmediate loss, regardless of speed
Cannot recover queued work after auth refresh or reconnectImmediate loss
Generated stable API contains unexpected dynamicLoss unless the thin strict gate rejects it before commitNot a generated-code criterion
Both complete all 50 seeds with canonical equalityCompare profile results, API clarity, adapter size, dependency health, and maintenance cost
Runtime is tied within measurement noiseWins if the generated boundary materially removes JSON plumbingWins if Dartvex still needs broad custom generation or fragile patches
+
+ +

The expected best outcome is not “Dartvex untouched.” It is Dartvex plus a narrow Icarus strictness policy. If that produces complete, deterministic types and passes the same runtime gauntlet, Dartvex earns the win because it moves contract failures into analysis and removes hand-written JSON decoding. If the compensation grows into a package fork, a second generator, or recurring patches for common Convex validators, convex_flutter remains the more honest base.

+
+ +
+

Remote machine handoff

+ +

Start here

+
    +
  1. Check out t3code/convex-client-gauntlet and pull the latest commit.
  2. +
  3. Read this handoff, the first result note, and the server sync boundary handoff.
  4. +
  5. Reproduce the committed first gate before changing fixtures.
  6. +
  7. Implement Phase 1 as a distinct commit. Do not begin runtime work until every repaired contract check is green.
  8. +
  9. Implement the neutral interface and two adapters without changing local Hive models, .ica, UI, outbox semantics, revision rules, or server payload semantics.
  10. +
  11. Run correctness, then profile. Commit raw machine-readable results and a short human verdict. Leave the old result file intact as historical evidence.
  12. +
+ +

Current commands that exist

+
git switch t3code/convex-client-gauntlet
+git pull --ff-only
+
+cd tool/convex_client_gauntlet
+fvm dart pub get
+fvm dart run bin/run.dart
+fvm dart test
+fvm dart analyze
+
+cd ../..
+npx tsc --noEmit
+npm run test:convex
+fvm flutter test
+fvm flutter analyze --no-fatal-infos
+fvm flutter build web --no-tree-shake-icons
+

These reproduce the committed first gate and repository baseline. Add named contract-v2, runtime, and profile entry points as part of the rerun; document their exact commands beside the resulting artifacts rather than pretending they already exist.

+ +

Required artifacts from the rerun

+
    +
  • Explicit-return baseline and actual result-renamed fixtures.
  • +
  • A strict wrapper test proving warnings and unexpected dynamic fail with a useful path.
  • +
  • Generated-output snapshots or hashes proving determinism.
  • +
  • Per-seed runtime JSON for both adapters, including fault schedule and canonical verifier hash.
  • +
  • Paired profile samples with run order, machine, build mode, and package versions.
  • +
  • A final matrix that distinguishes compile-time safety, runtime correctness, performance, and maintenance.
  • +
+
+ +
+

Acceptance checklist

+
    +
  • The result rename mutates a return field, not the function name, argument, caller, or baseline fixture.
  • +
  • The stable public Convex functions in scope have explicit return validators that match real payloads.
  • +
  • Dartvex baseline generation is warning-free and contains no unexpected dynamic.
  • +
  • The unchanged caller fails analysis for function, argument, and result renames.
  • +
  • Unsupported validators fail before generated code can be committed.
  • +
  • Both adapters receive byte-for-byte equivalent operation traces and fault schedules.
  • +
  • All 50 seeds end with exact canonical equality and no unresolved op.
  • +
  • Auth refresh is performed with the client session only; no elevated credential appears anywhere.
  • +
  • All exported strategies and library backups still round-trip.
  • +
  • No winner is declared from the tooling gate alone.
  • +
+
+ +
+

Appendix

+
+ Raw fairness evidence · first gate and diagnostic control +
Committed first gate
+  result-field step: generate('folders_list_for_parent.json')
+  baseline fixture: "returns": null
+  runtime seeds: 0
+
+Diagnostic missing control
+  explicit typed baseline analysis: exit 0
+  publicId -> folderPublicId analysis: exit 3
+  failure: undefined getter on the unchanged caller
+
+Correct interpretation
+  Dartvex 0.2.0 is not fail-closed by default.
+  The original test did not compare runtime clients.
+  A complete result schema lets the generated caller catch result drift.
+

Captured 2026-08-26 against branch base df9f1934bbc7ab71e144208036f385b8f73c77aa. The diagnostic fixture was intentionally not committed; the fair implementation must add a reviewed equivalent.

+
+
+ +

Generated 2026-08-26 · Icarus cloud client evaluation · base df9f193 · Dartvex 0.2.0 · convex_flutter 3.0.1 · Supabase Flutter auth guidance checked 2026-08-26 · revision v1

+
+
+
+ + + + diff --git a/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_result.md b/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_result.md new file mode 100644 index 00000000..b268d02a --- /dev/null +++ b/docs/cloud_sync_refactor/convex_dart_client_fair_rerun_result.md @@ -0,0 +1,185 @@ +# Convex Dart client fair rerun result + +Status: complete on 2026-08-27 + +Harness and repair commit: `fb83488c0924f8daf57c4bdfc48d7a4a5ff0c8f5` + +Candidates: Dartvex 0.2.0 and `convex_flutter` 3.0.1 with the Icarus auth and +reconnect repair + +Decision: keep `convex_flutter`; do not migrate to Dartvex. + +## Verdict + +The earlier “Dartvex wins” statement was not a fair final verdict. Dartvex had +passed while `convex_flutter` was blocked by a package auth defect, so the run +had identified a broken candidate rather than measured two valid candidates. + +That defect is now fixed at the package and Rust-client layers. Both candidates +completed all 50 deterministic seeds and all 50,000 ops. Each recorded 45,500 +landed ops, 4,500 planned visible revision rejects, zero unresolved ops, exact +once-only replay after auth refresh, durable checkpoint recovery, 50 matching +canonical verifier hashes, and 50 successful `.ica` round-trips. + +There is therefore no correctness winner. In the valid paired profile, +`convex_flutter` was 31.9% faster by median runner wall time and 34.0% faster on +real reconnect-to-live time. Dartvex used 5.1% less peak RSS, 36.4% less CPU in +relative terms (5.29 percentage points), and 12.9% less transfer than +`convex_flutter` (14.9% more when expressed from the Dartvex baseline). Remote +convergence differed by only 0.156 ms at the median. + +Those are trade-offs, not a blanket performance winner. Icarus should keep its +current client because Dartvex's stable generated return surface is still +incomplete for the runtime functions, so migrating would not yet remove the +path-and-JSON boundary that motivated the evaluation. The cost is explicit: +the current repair vendors both `convex_flutter` and Convex Rust 0.10.4 until +equivalent fixes are published upstream. + +## Auth repair + +The failure had four interacting causes: + +- the published Flutter adapter owned a separate token-expiry timer and fed the + Rust client static auth, outside the state machine that replays auth, + subscriptions, and in-flight mutations after reconnect; +- disposing an old refresh handle could asynchronously clear a newer auth + callback; +- the public native `reconnect()` method was an authenticated health query, not + a socket transition; +- Convex Rust applied independent client-worker and WebSocket network backoffs + to one auth rejection. A fresh token could sit behind a 15-second backoff, + while stale responses and connection metadata could start further protocol + recovery loops. + +The repair gives the token callback to the upstream Convex state machine, adds +generation ownership to auth handles, implements a real reconnect that waits +for connecting then connected, and patches Convex Rust to preserve one session +identity with monotonic connection counts. Auth rejection now uses a bounded +250 ms callback cadence, coordinates that state with the WebSocket worker so it +does not add a second network backoff, discards responses from the replaced +socket, and only exits auth recovery after a changed token receives a valid +server response. + +Ten consecutive debug calibration runs passed before the authoritative rerun. +In the final profile samples, `convex_flutter` accepted the refreshed token in +73.848–171.027 ms; all queued work landed exactly once. + +## Fair workload + +Both adapters used the same isolated local Convex deployment, disposable client +account, public Supabase anon credential, base fixture, serialized op traces, +IDs, timeouts, and fault schedules. The seed-0 trace and schedule hashes match: + +- trace: `ddf6d41ed9ccdbf3c60766fe6b0318218dd8954d8615fcffb2a8daefe849aa06` +- fault schedule: + `b4e244c38b89789f1191e988768aa681f4722184533077ef58eb6f95ee6a0e52` +- base fixture: + `test/fixtures/strategy_integrity/base-test-v43.ica`, SHA-256 + `8544873d608a0ad885b2e6042a383596a0b1dc37514034281b4e4eec6168756a` + +The 1,000-op trace covers strategy, page, page content, element, and lineup +changes, including add, patch, reorder, delete/recreate, duplicate delivery, +revision conflicts, subscription restart, real reconnect, offline delay, auth +rejection/refresh, and durable process restart. A clean Dartvex client acts as +verifier C. Canonical state excludes server-authored transport clocks. + +## Correctness + +| Result | Dartvex | `convex_flutter` repaired | +| --- | ---: | ---: | +| Seeds | 50/50 | 50/50 | +| Operations | 50,000/50,000 | 50,000/50,000 | +| Landed | 45,500 | 45,500 | +| Planned visible rejects | 4,500 | 4,500 | +| Unresolved | 0 | 0 | +| Refreshed token accepted | yes | yes | +| Queued auth-fault batch landed once | yes | yes | +| Persisted checkpoint resumed | yes | yes | +| Canonical verifier and `.ica` round-trip | 50/50 | 50/50 | + +The debug correctness reports recorded 64,346.843 ms total runner wall time and +256,311,296 bytes maximum RSS for Dartvex, versus 74,446.106 ms and 266,141,696 +bytes for `convex_flutter`. These prove completion but are not used as the +performance comparison; the paired profile build below is authoritative for +that. + +## Paired macOS profile + +Ten paired profile-build trials were run per candidate, alternating which +candidate ran first and replacing deployment data before every run. CPU is +defined as process user plus system seconds divided by the runner's wall-clock +window. P95 uses nearest rank, so with ten samples it is the maximum observed +sample. + +| Metric | Dartvex median / p95 | `convex_flutter` median / p95 | +| --- | ---: | ---: | +| Remote convergence | 7.304 / 7.804 ms | 7.459 / 7.575 ms | +| Reconnect to live | 110.893 / 150.191 ms | 73.243 / 94.765 ms | +| Fresh token accepted | 9.144 / 9.662 ms | 127.134 / 171.027 ms | +| Full auth recovery | 147.883 / 166.973 ms | 205.734 / 543.221 ms | +| Runner wall time | 2,749.544 / 3,396.189 ms | 1,873.387 / 2,312.830 ms | +| Peak RSS | 134,406,144 / 134,856,704 B | 141,312,000 / 141,541,376 B | +| Average process CPU | 9.248% / 10.118% | 14.533% / 15.306% | +| Application JSON transfer | 1,439,001 / 1,439,001 B | 1,652,716 / 1,771,501 B | + +The shared universal macOS harness bundle is 78,540,800 bytes and contains both +adapters, so candidate-isolated bundle size is not available. The +`convex_flutter` native framework executable is 23,195,280 bytes; the shared App +framework executable is 8,000,528 bytes. The build contains arm64 and x86_64 +slices and ran on an arm64 Mac. Windows and Linux remain package-supported but +could not be built or measured from this macOS host; they are recorded as +unmeasured rather than silently generalized. + +## Separate scorecards + +| Area | Dartvex 0.2.0 | `convex_flutter` repaired | +| --- | --- | --- | +| Runtime correctness | pass | pass | +| Rejected-token recovery | pass | pass after package/Rust repair | +| Real reconnect | pass | pass after replacing health-query implementation | +| Median wall time | slower | faster | +| CPU, RSS, transfer | lower | higher | +| Generated contract boundary | strict wrapper passes, runtime return surface incomplete | none; hand-written JSON boundary | +| Maintenance | pure Dart package plus local strict gate | vendored Flutter package, Rust crate, generated bridge, and pinned FRB 2.11.1 | + +## Artifacts + +- [`contract_gate.json`](../../tool/convex_client_gauntlet/results/contract_gate.json) +- [`dartvex_correctness.json`](../../tool/convex_client_gauntlet/runtime/results/dartvex_correctness.json) +- [`convex_flutter_correctness.json`](../../tool/convex_client_gauntlet/runtime/results/convex_flutter_correctness.json) +- [`paired_profile_macos.json`](../../tool/convex_client_gauntlet/runtime/results/paired_profile_macos.json) +- [`fair_rerun_matrix.json`](../../tool/convex_client_gauntlet/runtime/results/fair_rerun_matrix.json) +- [runtime commands](../../tool/convex_client_gauntlet/runtime/README.md) +- [`convex_flutter` patch notes](../../third_party/convex_flutter/ICARUS_PATCH.md) +- [Convex Rust patch notes](../../third_party/convex_rs/ICARUS_PATCH.md) + +The raw artifacts contain no email, password, access token, refresh token, +Supabase key, Convex admin key, or elevated credential. The deployment was the +isolated local instance at `127.0.0.1:3210`; no production or user library data +was used. + +## Verification + +- The contract gate passed all six checks; its two regression tests and Dart + analysis passed. +- The runtime harness passed four workload tests, with the environment-gated + transport smoke test skipped, and both the runtime and nested app analyzed + cleanly. +- The Icarus suite passed all 343 tests, including all 15 auth-provider tests. + TypeScript analysis passed and the 22 Convex boundary tests passed. +- The Icarus app analyzed with six pre-existing info lints and no warning or + error. Web and macOS release builds passed with icon tree shaking disabled; + the macOS app bundle was 97.8 MB. +- Convex Rust passed all 36 tests. The native Flutter package passed Cargo + check/test, and its Dart `lib/` analyzed with no warning or error. +- All four checked-in JSON artifacts parse, their recorded SHA-256 values + match, the final diff has no whitespace errors, and the artifact secret scan + passed. + +## Next step + +Keep `convex_flutter`, upstream the package and Convex Rust repairs, and remove +the local vendors when published releases pass this same gate. Dartvex can be +reconsidered after its stable generated return surface covers the actual Icarus +runtime boundary and proves that a migration removes JSON plumbing instead of +moving it into another wrapper. diff --git a/docs/cloud_sync_refactor/convex_dart_client_gauntlet_result.md b/docs/cloud_sync_refactor/convex_dart_client_gauntlet_result.md new file mode 100644 index 00000000..e123e867 --- /dev/null +++ b/docs/cloud_sync_refactor/convex_dart_client_gauntlet_result.md @@ -0,0 +1,120 @@ +# Convex Dart client gauntlet result + +Status: first gate recorded; runtime decision reopened on 2026-08-26 + +Base: `origin/icarus-cloud` at +`e59402eedee9035cf14693fbd26fe8b097d6abfa` on 2026-08-26 + +Candidate versions: `dartvex` 0.2.0 and `dartvex_codegen` 0.2.0 + +Decision: no client winner yet + +## Fairness correction + +The first gate found a real fail-open strictness gap in Dartvex 0.2.0, but its +result-field leg regenerated the baseline fixture instead of exercising a +renamed result fixture. Because that baseline declares `returns: null`, it +could not test whether a typed generated caller catches result drift. + +A diagnostic control with an explicit object return passed baseline analysis +and then failed analysis with exit 3 after `publicId` was renamed to +`folderPublicId`. The runtime comparison still has not run. The corrected, +authoritative next-step plan is +[convex_dart_client_fair_rerun_handoff.html](convex_dart_client_fair_rerun_handoff.html). + +## Result + +The original run stopped at the compile-time contract gate and therefore did +not run the runtime chaos or profile stages. Treat that stop as a recorded +strictness finding, not as a final package verdict. + +The stable `folders:listForParent` function declares argument validators but +no `returns:` validator. Convex represents that result as `returns: null` in a +function spec. Dartvex 0.2.0 deliberately maps the absent result contract to +`Future`. A caller that reads `result.first.publicId` still passes Dart +analysis. The committed runner did not actually rename a result field, so this +observation does not establish whether a complete generated return type catches +that change. + +Dartvex also treats an unknown validator as a warning and generates the +affected field as `dynamic`. Generation exits zero instead of stopping at the +function and field path. + +These are declared losing conditions in the comparison plan. They are not +performance observations and cannot be averaged away. + +| Contract check | Required | Observed | Result | +| --- | --- | --- | --- | +| Function rename | Old method fails analysis | Analysis exits 3 | Pass | +| Argument rename | Old named argument fails analysis | Analysis exits 3 | Pass | +| Result-field rename | Old field access fails analysis | No result rename was exercised; baseline return is `dynamic`; analysis exits 0 | **Invalid leg** | +| Unsupported validator | Generation exits nonzero with a path | Warning with path; generation exits 0 and emits `dynamic` | **Fail** | +| Second generation | No repository diff | No generated-file change | Pass | + +## Reproduce + +The evaluation lives in an isolated Dart package so the rejected candidate is +not added to the Icarus application or its lockfile. + +```bash +cd tool/convex_client_gauntlet +fvm dart pub get +fvm dart run bin/run.dart +fvm dart test +fvm dart analyze +``` + +The committed machine-readable result is +[`tool/convex_client_gauntlet/results/contract_gate.json`](../../tool/convex_client_gauntlet/results/contract_gate.json). +The runner regenerates bindings for the baseline, function-rename, +argument-rename, and unsupported-validator fixtures and compiles the same old +caller after each relevant change. + +## Runtime stage + +Skipped by the comparison's explicit stop rule: + +> If Dartvex generates function names but leaves public results as `dynamic`, +> stop the runtime comparison and record that gap before writing new generator +> code. + +Consequently, this result makes no claim about Dartvex runtime correctness, +latency, memory, reconnect behavior, auth recovery, or platform builds. The +recorded counts are zero because the runtime workload was not started, not +because either client completed it without faults. + +No client abstraction, adapter, application migration, custom generator, +production deployment, local Hive model, `.ica` format, outbox, revision rule, +or server payload changed in this comparison. + +## Refreshed baseline + +Before the gate, the refreshed cloud base passed: + +- `npm ci` (with the existing npm audit report of 4 dependency + vulnerabilities: 2 moderate, 1 high, 1 critical) +- `npx tsc --noEmit` +- `npm run test:convex` (22 tests) +- the six focused Flutter files named by the two handoffs (82 tests) + +After recording the decision, the comparison branch passed: + +- `fvm dart test` in `tool/convex_client_gauntlet` (1 test) +- `fvm dart analyze` in `tool/convex_client_gauntlet` (no issues) +- `npx tsc --noEmit` +- `npm run test:convex` (22 tests) +- `fvm flutter test` (343 tests) +- `fvm flutter analyze --no-fatal-infos` (exit 0 with the same 6 + pre-existing info-level lints) +- `fvm flutter build web --no-tree-shake-icons` + +The exact `fvm flutter build web` command still fails on the refreshed base's +three existing non-constant `IconData` sites in `folder_provider.dart`, +`hive_adapters.g.dart`, and `archive_manifest.dart`. This comparison does not +change those files. Disabling icon tree shaking proves the web target otherwise +compiles; the pre-existing release-build cleanup remains separate work. + +The next comparison may add explicit public result validators and a thin, +Icarus-owned strict wrapper that rejects warnings and unexpected `dynamic`. +Writing a replacement generator or package fork remains out of scope until that +smaller compensation is tested. diff --git a/docs/cloud_sync_refactor/convex_sync_refactor_plan.md b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md new file mode 100644 index 00000000..5497ebb4 --- /dev/null +++ b/docs/cloud_sync_refactor/convex_sync_refactor_plan.md @@ -0,0 +1,210 @@ +# Convex cloud sync refactor plan + +Status: historical first-pass plan. Its strategy-level subscription work is +already present in this branch. Do not continue its sequence-preserving design. +For the current server boundary and durable outbox work, follow +[`server_side_sync_boundaries_handoff.md`](server_side_sync_boundaries_handoff.md). + +## Summary + +Intent: refactor Icarus Cloud's Convex sync/query/subscription plumbing so it uses Convex more efficiently while preserving the current UI and all existing product behavior. No visual changes, no feature removals, and no intentional workflow changes. Any currently synced editor state must continue to sync; if inspection finds a currently local-only field that should be cloud-backed, add it to the cloud sync path with tests. + +## Scope + +In scope: Convex queries/mutations, Flutter repository/provider data plumbing, typed model helpers, sync coverage audit, backend/client tests, and performance-oriented query shape changes. + +Out of scope: UI redesign, new conflict UI, product behavior changes, changing the sequence/conflict model, and digest-table migrations unless explicitly approved later. + +## Current Problems To Address + +1. `remote_strategy_snapshot_provider.dart` subscribes to granular Convex data but mostly treats updates as dirty flags, then refetches the whole strategy snapshot. +2. Open strategies use per-page `elements:listForPage` and `lineups:listForPage` subscriptions, creating `3 + 2 * pageCount` active subscriptions. +3. Cloud library queries scan broad tables and filter in TypeScript, especially `strategies.ts` and `folders.ts`. +4. Cloud library summaries are computed from source tables each time; a digest table may help later, but should not be the first change. +5. Deferred follow-up: parent `strategy.sequence` / `updatedAt` is a hot invalidation point, but it is also the current remote change clock, so do not change it in this refactor. + +## Public API And Type Changes + +Add Convex public queries: + +- `elements:listForStrategy({ strategyPublicId })` +- `lineups:listForStrategy({ strategyPublicId })` +- `strategies:listSharedWithMe({})` if still missing in the current backend + +Keep existing queries for compatibility: + +- `elements:listForPage` +- `lineups:listForPage` +- `strategies:listForFolder` +- `pages:listForStrategy` +- `images:listForStrategy` +- `strategies:getHeader` + +Add Flutter repository methods: + +- `listElementsForStrategy(strategyPublicId)` +- `listLineupsForStrategy(strategyPublicId)` +- `watchStrategyHeader(strategyPublicId)` already exists; keep it. +- `watchPagesForStrategy(strategyPublicId)` +- `watchImageAssetsForStrategy(strategyPublicId)` +- `watchElementsForStrategy(strategyPublicId)` +- `watchLineupsForStrategy(strategyPublicId)` + +Add model helpers: + +- `RemoteStrategySnapshot.copyWith(...)` +- helper methods that replace header, pages, assets, elements, or lineups without mutating unrelated snapshot sections +- grouping helpers that convert strategy-level element/lineup lists into `elementsByPage` and `lineupsByPage` + +## Implementation Checklist + +### 1. Create The Workflow Doc + +- [x] Create `docs/cloud_sync_refactor/convex_sync_refactor_plan.md` and save this plan there. +- [x] Preserve this file as the historical checklist for the first query refactor. + +### 2. Add Strategy-Level Element And Lineup Queries + +- [x] In `convex/elements.ts`, add `listForStrategy`. +- [x] Use `getStrategyByPublicId`, `assertStrategyRole(ctx, strategy, "viewer")`, query `elements` with `by_strategyId`, and query `pages` with `by_strategyId` to map internal `pageId` values back to page `publicId`. +- [x] Return the same client shape as `listForPage`, including `publicId`, `strategyPublicId`, `pagePublicId`, `elementType`, `payload`, `sortIndex`, `revision`, `deleted`, `createdAt`, and `updatedAt`. +- [x] In `convex/lineups.ts`, add `listForStrategy` with the same authorization and page lookup pattern. +- [x] Do not remove the page-level queries. + +### 3. Refactor Snapshot Fetching + +- [x] Update `ConvexStrategyRepository.fetchSnapshot` so it calls `strategies:getHeader`, `pages:listForStrategy`, `images:listForStrategy`, `elements:listForStrategy`, and `lineups:listForStrategy`. +- [x] Remove the loop that calls `elements:listForPage` and `lineups:listForPage` for every page. +- [x] Group returned elements and lineups by `pagePublicId`. +- [x] Preserve the same `RemoteStrategySnapshot` shape, page ordering, and deleted-row handling downstream. + +### 4. Use Subscription Payloads Directly + +- [x] Update `RemoteStrategySnapshotNotifier` so subscription callbacks decode their payloads and update only the relevant snapshot section. +- [x] Header update replaces only `snapshot.header`. +- [x] Pages update replaces `snapshot.pages`, updates available page IDs, and prunes maps only for pages that no longer exist. +- [x] Assets update replaces only `snapshot.assetsById`. +- [x] Elements update replaces only `snapshot.elementsByPage`. +- [x] Lineups update replaces only `snapshot.lineupsByPage`. +- [x] Initial open still performs a full `fetchSnapshot`. +- [x] Manual `refresh()` still performs a full `fetchSnapshot`. +- [x] Subscription errors still fall back to the existing refresh/error path. +- [x] Auth incident behavior remains unchanged. + +### 5. Collapse Per-Page Subscriptions + +- [x] Replace the per-page element and lineup subscription maps in `RemoteStrategySnapshotNotifier` with one strategy-level element subscription and one strategy-level lineup subscription. +- [x] Active strategy subscription set becomes `strategies:getHeader`, `pages:listForStrategy`, `images:listForStrategy`, `elements:listForStrategy`, and `lineups:listForStrategy`. +- [x] Remove `_syncPageSubscriptions` / `_syncPageWatchersFromIds` after strategy-level subscriptions are working. +- [x] Keep cleanup behavior in `_disposeSubscriptions`. + +### 6. Preserve Current Editor Behavior + +- [ ] Do not change `StrategyPageSessionNotifier` behavior except where needed to consume the updated snapshot shape. +- [ ] Preserve `header.sequence` as the signal that triggers remote page rehydration. +- [ ] Preserve pending local overlays over remote data. +- [ ] Preserve ack reconciliation refresh behavior when needed. +- [ ] Preserve conflict rejects through `strategyConflictProvider`. +- [ ] Preserve page switch flushes before changing active pages. +- [ ] Preserve media asset URL hydration. + +### 7. Fix Cloud Library Query Shapes + +- [ ] Refactor `convex/strategies.ts` to avoid global table scans for normal user-scoped views. +- [ ] Refactor concrete folder strategy listing to resolve the folder and query `strategies` with `by_folderId`. +- [ ] Refactor owned root strategies to query `strategies` with `by_ownerId` and keep only `folderId === undefined`. +- [ ] Refactor direct shared root strategies to query `strategyCollaborators` with `by_userId`, fetch those strategies, and keep non-owned root strategies. +- [ ] Preserve effective role checks through `getEffectiveStrategyRoleForUser`. +- [ ] Preserve returned `CloudStrategySummary` fields and sorting. +- [ ] Refactor `convex/folders.ts` to use `by_ownerId`, `by_parentFolderId`, and `folderCollaborators.by_userId` instead of scanning all folders. +- [ ] Preserve current visible hierarchy semantics unless a test proves existing behavior is broken. +- [ ] Keep inherited folder role behavior by traversing descendants from directly shared folders through `by_parentFolderId`. +- [ ] Add or fix `strategies:listSharedWithMe` so the Flutter repository call has a real backend function. + +### 8. Sync Coverage Audit + +Must remain synced: + +- [ ] strategy metadata: name, map, theme profile, theme override +- [ ] page data: name, order, attack/defense side, settings +- [ ] elements: agents, abilities, drawings, text, images, utilities +- [ ] lineups and lineup image references +- [ ] image asset metadata and URLs +- [ ] deletes, moves, reorder, payload patches +- [ ] role/capability behavior for owner/editor/viewer + +If a currently user-visible strategy/page field is local-only but should be cloud-backed, add serialization, Convex op handling, hydration, and tests in the same refactor. + +### 9. Digest Table Decision + +Do not implement digest tables in the first pass. + +Follow-up design: + +- Candidate table: `strategyDigests` +- Candidate fields: strategy id/public id, owner id, folder id, name, map data, role-facing summary fields, attack label, created/updated timestamps +- Maintenance points: strategy create/update/move/delete, page add/patch/delete/reorder, share/collaborator changes +- Trigger condition: implement only if optimized indexed library queries still show high read bytes or subscription churn + +### 10. Superseded follow-up + +This plan deferred the `strategy.sequence` hot-write. The current handoff linked +at the top now owns that change and replaces sequence-gated hydration. + +## Tests And Verification + +Add or update Dart unit tests for snapshot replacement helpers: + +- [ ] header update preserves pages/assets/elements/lineups +- [ ] pages update preserves unchanged page maps and prunes removed pages +- [ ] strategy-level elements are grouped by page +- [ ] strategy-level lineups are grouped by page +- [ ] deleted elements/lineups remain present in remote data but are ignored during hydration as today + +Add or update provider tests: + +- [ ] opening a cloud strategy does one full fetch then uses subscription payloads +- [ ] a header update triggers the same rehydration behavior as before +- [ ] an element update on the active page updates remote snapshot without full refetch +- [ ] a lineup update on the active page updates remote snapshot without full refetch +- [ ] page deletion/reorder keeps active page resolution behavior unchanged +- [ ] ack rejection still records a conflict and refreshes/rebases as before + +Add Convex tests if the test harness is introduced for this work: + +- [ ] `elements:listForStrategy` enforces viewer access +- [ ] `lineups:listForStrategy` enforces viewer access +- [ ] strategy-level queries return page public IDs correctly +- [ ] `strategies:listForFolder` does not expose unauthorized strategies +- [ ] `strategies:listSharedWithMe` returns direct shared strategies +- [ ] folder-shared access still works through inherited folder roles + +Run verification commands: + +```powershell +npx tsc --noEmit +fvm flutter test test\strategy_page_session_provider_test.dart test\strategy_op_queue_provider_test.dart test\collab_sync_models_test.dart test\cloud_ui_parity_helpers_test.dart +fvm flutter analyze +``` + +Expected `fvm flutter analyze` result: no errors; pre-existing warnings/infos may remain. + +## Acceptance Criteria + +- [ ] No visible UI changes. +- [ ] No feature removal. +- [ ] Opening, editing, page switching, collaboration updates, media hydration, conflict handling, and library browsing behave the same as before. +- [ ] Active strategy subscriptions no longer scale with page count. +- [ ] Subscription payloads update local snapshot state directly instead of forcing full snapshot refreshes. +- [ ] Full snapshot refresh remains available for initial load, manual refresh, auth recovery, and error recovery. +- [ ] Cloud library queries no longer scan all strategies or all folders for normal user-scoped views. +- [ ] The saved plan contains a clear deferred note for the sequence/update hot-write concern. + +## Assumptions And Defaults + +- Scope is data plumbing only. +- Target documentation location is `docs/cloud_sync_refactor/convex_sync_refactor_plan.md`. +- Keep current UI exactly as-is. +- Historical choice: this pass kept `strategy.sequence` as the remote change clock. +- Do not implement digest tables until after indexed query cleanup is measured. +- Preserve current backend function names where possible; only add new functions for better Convex query shapes. diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html b/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html new file mode 100644 index 00000000..6ef944fa --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_blueprint.html @@ -0,0 +1,929 @@ + + + + + + + + + Icarus server-side sync boundaries + + + + + + + +
+
+
+ + ◆ RFC + +
+
+
+ + + +
+ + +
+
+

Reactive boundary · Proposed · High confidence

+

The server should synchronize one active page, not rebuild the whole strategy

+

Icarus currently has one live query that reads five logical record groups: the strategy, every page, every element, every lineup, and every referenced asset. Convex treats that query as one reactive dependency graph, so an edit on any page can rerun and resend the whole strategy snapshot. The proposed model uses two live queries, one stable strategy shell and one active-page snapshot. Entity revisions keep conflict checks precise, while full-strategy reads remain available for export and recovery without staying subscribed.

+ +
+

The decision

+ +
+
5 groupscurrent live read set
+
2 queriesproposed editor subscriptions
+
1 pagenormal content invalidation
+
0 logsnew delta logs required
+
+ +

Recommended decision: keep Convex and the existing active-page client overlay. Split the server read model into a strategy shell and an active-page snapshot, then stop advancing the strategy row for ordinary page content edits.

+ +

This is not a database rewrite. Most current tables, payloads, indexes, operation IDs, and entity revisions remain useful. The main change is deciding which records each subscribed query may read and which parent records each mutation may write.

+ +

Confidence high The repository proves the current dependency graph. Convex documents that subscribed queries rerun when data they depend on changes. Not measured This document does not claim a byte, latency, or billing reduction because no production workload exists yet.

+
+ +
+

Why a server boundary matters in Convex

+ +

In a conventional request API, an endpoint usually runs once and returns. A Convex query can stay subscribed. Convex records what the query read, then reruns it when relevant database data changes. That makes the query's read set the real synchronization boundary.

+ +

Splitting Flutter providers does not narrow server work if they still subscribe to one broad query. Normalizing data into separate tables also does not help if one query reads every table. The boundary becomes narrow only when the query arguments and indexes let it read a narrow set of records.

+ + +

Sources: convex/snapshot.ts, convex/ops.ts, and Convex reactive query overview. Diagram shows code paths, not measured traffic.

+ +

What happens when one agent moves

+
    +
  1. The active-page overlay creates an element.patch with that element's expected revision.
  2. +
  3. ops:applyBatch patches the element and then increments strategies.sequence and strategies.updatedAt.
  4. +
  5. snapshot:get already depends on that element. It also depends on the parent strategy row and every other page child it collected.
  6. +
  7. Convex reruns snapshot:get. The function reads and serializes every page, element, lineup, and referenced asset again.
  8. +
  9. Flutter receives a new RemoteStrategySnapshot. The active-page overlay protects pending local intent while the snapshot replaces the remote base.
  10. +
+ +

The overlay solves correctness on the client. It cannot shrink the server query that produced the remote base. That is why the existing active-page work and the missing server boundary are separate problems.

+
+ +
+

The proposed server boundaries

+ +

An open editor needs two changing views. The first is the strategy shell: metadata, access role, and the ordered page list. The second is the active page: settings, elements, lineups, and only the assets those records reference.

+ + +

Proposed design derived from existing by_pageId indexes in convex/schema.ts. No schema or query in this diagram has been implemented yet.

+ +
+ + + + + + + + +
Server readLifetimeMay readMust not readWhy it changes
strategy:getShellSubscribed while editor is openStrategy metadata, role, page descriptorsElements, lineups, page content settings, assetsRename, theme, page add/delete/reorder, role change
page:getSnapshotSubscribed for active page onlyOne page's content, entities, referenced assetsOther pages' entities or assetsEdit on active page, asset status change, page deletion
strategy:getFullSnapshotOne-shotEverything required for round-tripNothing required for export may be omittedExplicit export, import verification, recovery
strategies:listForFolderSubscribed while library is visibleSmall summaries and access dataCanvas entities and media payloadsLibrary metadata or membership change
+
+

Source: proposed query contract. The full snapshot remains deliberately broad, but no editor keeps it subscribed.

+
+ +
+

The data model that makes the boundary real

+ +

The key split is inside today's pages document. A shell query needs page descriptors, but it does not need mutable canvas settings. Convex tracks documents read by a query. Mapping only a few fields in TypeScript does not turn a page document into two dependencies. Moving page settings into a one-row pageContents table gives the shell and page snapshot separate records to depend on.

+ + +

Source: proposed model over the current schema in convex/schema.ts. Existing entity, asset, and event rows stay recognizable.

+ +
+ + + + + + + + + + + + +
TableKeepChangeRevision meaning
strategiesIdentity, ownership, folder, name, map, themeReplace global content sequence with metadata or structure revisionOnly strategy metadata and page structure changed
pagesID, strategy ID, name, order, sideMove canvas settings outThis page descriptor changed
pageContentsNew one-to-one rowOwn page settings independentlySettings changed
elementsCurrent payload, tombstone, revision, by_pageIdNo structural change requiredThis element changed
lineupsCurrent payload, tombstone, revision, by_pageIdNo structural change requiredThis lineup changed
imageAssetsCurrent ownership and upload fieldsFetch by referenced ID for the active pageAsset status remains its own state
operationEventsIdempotency and audit fieldsGlobal expected sequence becomes entity or metadata revisionThe acknowledged entity version
strategyActivityNothing yetOptional small library-only rowLast content activity, if product needs it
+
+

Source: proposed schema responsibilities. The server is still pre-release clay, so this can replace the deployed shape without migrating public cloud data.

+ +

Why the first version should not add a page sequence

+

A page-level sequence looks like the natural replacement for the strategy sequence, but the reactive query already watches the active page's rows. Element and lineup revisions already detect conflicting edits to the same entity. Writing one page sequence after every drag would create a new hot row for everyone editing that page.

+ +

The first version should use per-entity revisions and Convex query reactivity. Add a page sequence later only if a concrete recovery case cannot be expressed with query updates, operation acknowledgements, and entity revisions.

+
+ +
+

The mutation contract controls invalidation

+ +

The query split works only if mutations respect it. A normal content edit must stop patching the strategy row. Otherwise every active-page mutation still wakes the shell and any library query that reads the strategy.

+ +
+ + + + + + + + + + + +
OperationRows writtenConflict checkShellPage APage B
Move element on AElement A, eventElement revisionStableRerunsStable
Edit lineup on BLineup B, eventLineup revisionStableStableReruns
Change settings on APageContents A, eventSettings revisionStableRerunsStable
Move element A to BElement changes pageId, eventElement revisionStableRerunsReruns
Rename page APage descriptor A, eventPage revisionRerunsRerunsStable
Reorder pagesPage descriptors, strategy metadata revisionStructure revisionRerunsContent stableContent stable
Rename strategyStrategy, eventMetadata revisionRerunsAuth row read may rerunAuth row read may rerun
+
+

Source: proposed write matrix. "Stable" means the write does not intersect that query's intended database read set.

+ +

Conflict rules become easier to explain

+
    +
  • Two users edit different elements. Both operations land because neither depends on a global content sequence.
  • +
  • Two users edit the same element revision. One lands and the other receives a revision mismatch with the current payload.
  • +
  • Two users edit different pages. Each active-page subscription receives only its page's result.
  • +
  • A page moves or disappears while someone edits it. The shell update changes page membership, and the client flushes or surfaces the pending operation before leaving that page.
  • +
+ +

Convex still provides atomic mutations and retries internal optimistic concurrency conflicts. Icarus entity revisions solve a different problem: they detect user-level edits based on stale content and let the app explain the conflict instead of silently choosing a winner.

+

Source: Convex OCC and atomicity and current expected revision handling in convex/ops.ts.

+
+ +
+

The client can reuse the work that already exists

+ +

The active-page projection is not throwaway code. It already groups intent by page and entity, overlays queued and in-flight changes over the remote base, and compares entity revisions. The server split should feed that controller a smaller remote base.

+ + +

Source: existing page-scoped entity keys and overlay logic in active_page_live_sync_provider.dart and strategy_op_queue_provider.dart.

+ +

The durable outbox is a client boundary, but it completes the server design. A mutation acknowledgement proves the op landed. A query update supplies the new remote base. The outbox must preserve an unacknowledged op until one of those outcomes is visible to the user.

+
+ +
+

The smallest implementation cut

+ +
    +
  1. Add pageContents and reshape the empty Convex deployment. Keep current payload validators and generated client models aligned.
  2. +
  3. Add strategy:getShell, page:getSnapshot, and a one-shot strategy:getFullSnapshot. The page query must use by_pageId for elements and lineups.
  4. +
  5. Replace watchSnapshot with one shell subscription and one active-page subscription. Feed both into the existing projected-page logic.
  6. +
  7. Stop calling incrementSequence for element, lineup, and page-content operations. Keep metadata and structure revisions where stale writes need detection.
  8. +
  9. Persist the operation queue before calling the beta reliable. Remove the path that discards an op after eight failed attempts.
  10. +
  11. Measure query rows, bytes, reruns, and mutation conflicts. Add a library activity table only if users need content-based recency and the measurements support it.
  12. +
+ +
+ + + + + + + + + + +
ProofSetupExpected result
Cross-page isolationSubscribe device A to Page A, edit Page B from device BPage A query does not update
Same-page realtimeBoth devices open Page A, move different elementsBoth operations land and both clients converge
Same-entity conflictBoth devices patch one element from the same revisionOne reject is surfaced, no silent overwrite
Page switchEdit offline on A, switch to B, restart appA's pending op remains visible and retryable
Round-tripExport full strategy, import into a clean local libraryPages, settings, entities, lineups, and media match
Query budgetRecord Convex metrics for one element dragRows and bytes scale with active page, not strategy page count
+
+

Source: proposed acceptance tests. Performance rows need live Convex metrics before a numeric target is set.

+
+ +
+

What this design refuses to add

+
    +
  • No tunneled peer-to-peer transport. Convex remains the durable library and collaboration authority.
  • +
  • No custom delta log for normal synchronization. Convex reactive queries and entity revisions already provide the needed mechanism.
  • +
  • No subscription per inactive page. The editor keeps one page content subscription and changes its argument on page switch.
  • +
  • No global content sequence. Different entities and pages should not conflict merely because their edits happened near each other.
  • +
  • No claim that the refactor lowers cost by a specific amount until live query metrics exist.
  • +
+
+ +
+

Evidence and assumptions

+
+ Current dependency evidence · repository paths and commands +
# Current whole-snapshot reads
+nl -ba convex/snapshot.ts | sed -n '12,110p'
+
+# Current parent sequence write
+nl -ba convex/ops.ts | sed -n '17,29p'
+rg -n 'incrementSequence' convex/ops.ts
+
+# Current Flutter subscription
+nl -ba lib/collab/convex_strategy_repository.dart | sed -n '194,207p'
+nl -ba lib/providers/collab/remote_strategy_snapshot_provider.dart | sed -n '95,117p'
+
+# Existing page indexes and entity revisions
+nl -ba convex/schema.ts | sed -n '37,99p'
+

Verified against commit 277fc14 in worktree t3code-84485e64 on 2026-08-24.

+
+ +
+ Assumptions · decisions that need product confirmation +
    +
  • The active editor needs realtime content for one page at a time.
  • +
  • Page name, order, and side belong in the strategy shell because the page list displays them.
  • +
  • Canvas settings can move into pageContents without changing the exported .ica shape.
  • +
  • Library sorting does not need to change on every drag for the first beta. If it does, use a library-only activity record and keep it out of editor queries.
  • +
  • The current cloud deployment contains no public user data and can be wiped for the schema change.
  • +
+
+
+ +

Generated 2026-08-24 · Codex with Dara · inputs: commit 277fc14, current Convex schema, snapshot query, mutation path, Flutter sync providers, official Convex reactive query and OCC documentation · revision v1

+
+
+
+ + + + diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md b/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md new file mode 100644 index 00000000..2bf41d22 --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_delivery.md @@ -0,0 +1,115 @@ +# Delivery gate: verify and babysit page-scoped sync + +Open this file only after the automated gate in +[`server_side_sync_boundaries_handoff.md`](server_side_sync_boundaries_handoff.md) +passes. This is the final sequence. The work is incomplete until the last +criterion passes. + +## Run the visible proof with Computer Use + +Automated tests establish the contract. The visible proof establishes that the +app keeps its promise. + +Launch two clients with independent local persistence: + +- Client A: `fvm flutter run -d macos` +- Client B: `fvm flutter run -d chrome`, or a second physical device if the web + build cannot complete the current auth flow + +Two `open -n` copies of the macOS app are not independent. They share the same +Application Support directory and Hive boxes, so that setup cannot prove +remote convergence. + +Use the Computer Use skill for the native Icarus window. Start with a fresh app +state read, prefer accessibility elements, and fetch fresh state after every +action before reusing an element index. Use the product browser controls for +the web client when available. Screenshots support the written observations. +They do not replace them. + +Ask Dara to take over for credentials or an unexpected permission prompt. +Continue once both clients show the same signed-in cloud strategy. + +Create a strategy named `SYNC BOUNDARY PROBE` with pages `A ACTIVE` and +`B INACTIVE`. Give each page a distinct text element. Add an image or lineup +image to page B so asset scoping is exercised. + +Run and record these scenarios: + +| Scenario | Client actions | Required visible result | +| --- | --- | --- | +| inactive-page isolation | A stays on page A. B moves an element and edits a lineup on page B. | A's canvas does not rehydrate or flicker. Its page A content stays unchanged. Switching A to B reveals B's accepted edits. | +| same-page convergence | Both clients open page A and edit different entities. | Both edits land and both clients converge. The sync chip reaches `Synced` only after acknowledgements. | +| same-entity conflict | Both clients edit one entity from the same revision. | One edit rejects or rebases through the explicit conflict flow. The losing intent is visible. No silent overwrite occurs. | +| page structure | B renames, adds, reorders, then deletes a non-active page. | A's page list updates through the shell. The active canvas changes only if its page disappears. | +| page switch with pending work | A edits page A and immediately switches to B. | Navigation stays responsive. Page A intent remains queued or lands. Returning to A shows the intended content. | +| full round-trip | Export the cloud strategy, import it into a clean local library, then export it again. | Pages, settings, entities, lineups, and media match semantically. | + +For the restart proof, temporarily disconnecting the Mac is a network-setting +change. Computer Use must ask for confirmation at action time. With approval: + +1. Disconnect after both clients have loaded. +2. Edit page A and verify `Offline` or `Attention`, never `Synced`. +3. Quit and reopen Icarus. +4. Verify the pending edit and status survived. +5. Reconnect and verify the same op lands once. + +Without approval, run the equivalent durable-outbox provider test and mark the +visible restart scenario as not run with that exact reason. + +Done when every scenario has an observed result, the setup identifies both +clients, and any skipped path has a concrete reason. + +## Measure the resource boundary + +Open the linked Convex development dashboard and isolate a short test window. +Compare ten element moves on an active page in a two-page strategy with ten +moves on the same-sized active page in a many-page strategy. + +Record query calls, rows read, bytes read or returned, and mutation conflicts +where the dashboard exposes them. The result should scale with active-page +content, not inactive page count. Report observed numbers. Set no invented +percentage target. + +Done when the evidence names the function, test window, page and entity counts, +observed metrics, and any dashboard metric that was unavailable. + +## Open the PR + +Refresh `origin/icarus-cloud` before opening the PR. Rebase or merge only with +the repository's normal policy. Open the PR against `icarus-cloud`, not `main`. + +The body must include: + +- the shell and active-page boundary; +- the write matrix and replay rule; +- the development deployment reset; +- automated command results; +- Computer Use scenario results; +- Convex resource observations; +- known follow-ups outside this assignment. + +Done when the PR exists against the correct base and its body contains the +actual proof results rather than a checklist of work still to run. + +## Babysit the current PR head + +Invoke the `github-pr-babysitter` skill. Record the PR number, URL, branch, +current head SHA, checks, review bots, and thread-aware review state. Poll active +reviews instead of reading an older badge as current. + +For every new head: + +1. Wait for the current bot run and CI. +2. Read review threads with `isResolved` and `isOutdated` data. +3. Fix narrow actionable findings. +4. Re-run proof proportional to the change. +5. Commit only the intended files and push. +6. Trigger the documented re-review mechanism. +7. Confirm a new review actually starts for the new SHA. + +For Greptile, green means a current-head 5/5 result. A top-level summary is +insufficient while an actionable thread remains. Leave the PR unmerged. + +Done when CI passes on the current head, the latest automated review applies to +that head, no actionable unresolved thread remains, the branch is clean and +pushed, and Dara receives the PR URL, head SHA, review result, and proof run. diff --git a/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md b/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md new file mode 100644 index 00000000..c7d9c15c --- /dev/null +++ b/docs/cloud_sync_refactor/server_side_sync_boundaries_handoff.md @@ -0,0 +1,431 @@ +# Handoff: make Convex sync page-scoped + +Status: ready for implementation + +Verified base: `277fc14` on 2026-08-25 + +PR target: `icarus-cloud` + +## The assignment + +Deliver one reviewable PR that makes the normal editor sync one strategy shell +and one active page. Keep the full strategy snapshot as a one-shot read for +export, import verification, and recovery. Persist the operation outbox so an +unacknowledged edit survives a page switch, app restart, expired auth, and +network loss. + +The PR is at its midpoint when it opens. Finish by running the visible desktop +proof and babysitting the current PR head until CI and automated review have no +actionable findings. Leave the PR unmerged for Dara. + +Read these before editing: + +1. [`AGENTS.md`](../../AGENTS.md) for the data and product rules. +2. [`CONTEXT.md`](../../CONTEXT.md) for Icarus vocabulary. +3. [`server_side_sync_boundaries_blueprint.html`](server_side_sync_boundaries_blueprint.html) for the architectural reasoning and diagrams. +4. [`DESIGN.md`](../../DESIGN.md) before changing any visible state. +5. [`auth_flow_reference.md`](../auth_flow_reference.md) if the verification path reaches sign-in or token recovery. + +## Fixed decisions + +Treat these as the contract, not design prompts. + +- Convex remains the durable cloud library and collaboration authority. +- An open editor has two live queries: `strategy:getShell` and + `page:getSnapshot` for the active page. +- `strategy:getFullSnapshot` is a one-shot query. No provider subscribes to it. +- A new `pageContents` row owns mutable page settings. The `pages` row remains + the descriptor used by the page list. +- An element, lineup, or page-content edit writes its own row and its operation + event. It does not patch the parent strategy. +- Conflict checks use the revision of the record being edited. There is no + global content sequence and no page-level sequence. +- `strategies.revision` covers strategy metadata and page collection structure. + It does not advance when canvas content changes. +- The library `updatedAt` does not change on every drag. Add a separate library + activity record later only if a measured product need justifies it. +- The outbox keeps a failed op. After the retry budget it becomes paused and + visible. It never discards the op. +- Local mode, `.ica` files, and library backups keep their current shape and + behavior. + +The normal edit path should have one sentence worth remembering: + +> The cost and conflict range of an edit match the record the user edited. + +## Starting evidence + +The current implementation has four useful pieces and three liabilities. + +Useful pieces: + +- `active_page_live_sync_provider.dart` already projects queued and in-flight + local intent over a remote base. +- `strategy_op_queue_provider.dart` already coalesces intent by page and entity. +- Elements and lineups already have `by_pageId` indexes and per-row revisions. +- Operation events already deduplicate `strategyId + clientId + opId`. + +Liabilities: + +- `snapshot:get` reads the strategy, every page, every element, every lineup, + and every referenced asset as one reactive read set. +- `ops:applyBatch` advances `strategies.sequence` and `updatedAt` for ordinary + content edits. +- The outbox is memory-only, regenerates `clientId`, and removes an op after + eight failed attempts. + +The nearest named branches do not contain the proposed boundary. At the +verified base, both are already ancestors of this branch: + +- `origin/cursor/convex-image-asset-storage-3cc3` introduced the broader + strategy-level query refactor. +- `origin/cursor/cloud-page-sync-overlay-6677` introduced the active-page + overlay. + +Before implementation, refresh refs and search named branches for +`pageContents`, `getShell`, and `getFullSnapshot`. Reuse newer work if it exists. +Do not cherry-pick either branch listed above into this base. + +Baseline proof at the verified base: + +```text +npx tsc --noEmit +PASS + +fvm flutter test \ + test/strategy_page_session_provider_test.dart \ + test/strategy_op_queue_provider_test.dart \ + test/collab_sync_models_test.dart \ + test/cloud_ui_parity_helpers_test.dart \ + test/strategy_integrity_test.dart +PASS, 55 tests +``` + +Refresh this baseline before changing code. Record drift in the PR body. + +## Target server model + +| Record | Owns | Revision changes when | +| --- | --- | --- | +| `strategies` | identity, owner, folder, name, map, theme, page collection revision | strategy metadata changes, or a page is added, deleted, or reordered | +| `pages` | `publicId`, strategy membership, name, order, side | that page descriptor changes | +| `pageContents` | one page's settings | those settings change | +| `elements` | one placed canvas entity | that entity changes | +| `lineups` | one lineup group | that lineup changes | +| `imageAssets` | upload and delivery state | that asset changes | +| `operationEvents` | idempotency result for one op | once, when the server accepts or rejects the op | + +`pageContents` is one-to-one with a page: + +```text +pageId Id<"pages">, indexed by_pageId +settings strategySettingsValidator, optional +revision number +createdAt number +updatedAt number +``` + +Create the page descriptor and page content row in the same mutation. Delete +the page content row when deleting its page. Keep the existing orphan cleanup +for elements and lineups. + +## Target read contract + +| Query | Lifetime | Reads | Must not read | +| --- | --- | --- | --- | +| `strategy:getShell` | subscribed while the editor is open | authorized role, strategy metadata, ordered page descriptors | page settings, elements, lineups, image assets | +| `page:getSnapshot` | subscribed for one active page | page descriptor, its `pageContents`, its elements, its lineups, referenced assets | records belonging only to another page | +| `strategy:getFullSnapshot` | one-shot | every record required to reconstruct and export the strategy | nothing required for a lossless round-trip may be omitted | + +The page query takes both `strategyPublicId` and `pagePublicId`. It verifies +membership and viewer access before returning data. It uses `by_pageId` for +elements and lineups. It resolves image assets only from IDs referenced by the +active page's element and lineup payloads. + +Use separate client types for the separate jobs: + +- `RemoteStrategyShell` +- `RemotePageSnapshot` +- `RemoteStrategySnapshot` or `RemoteFullStrategySnapshot` for the full + one-shot result + +Do not make one large type whose fields become nullable depending on which +query produced it. Export must accept only the full snapshot type. + +## Target write contract + +Use `expectedRevision` consistently. Its authority is the target record: + +| Operation | Checks | Writes | Legitimate reactive update | +| --- | --- | --- | --- | +| strategy metadata patch | `strategies.revision` | strategy row and operation event | shell and library | +| page add, delete, reorder | `strategies.revision` | affected page rows, strategy revision, page content row when applicable, operation event | shell and library | +| page descriptor patch | `pages.revision` | one page row and operation event | shell, plus active page if it is open | +| page content patch | `pageContents.revision` | one page content row and operation event | that active page | +| element add, patch, move, delete | element revision when the row exists | one element row and operation event | that active page | +| lineup add, patch, move, delete | lineup revision when the row exists | one lineup row and operation event | that active page | + +Add `pageContent` as an operation entity type. Keep page descriptor and page +content intent in separate entity keys so coalescing cannot merge two revision +domains. The active-page controller may produce both intents when one local +action changes side and settings. + +Remove `expectedSequence` and `appliedSequence` from the protocol, server +validator, event schema, acknowledgement model, client models, and tests. A +strategy-level operation uses `expectedRevision` against +`strategies.revision`. + +### Replay safety + +Operation events are currently retained for 30 days. A durable outbox can live +longer, so every operation must remain safe after its event has expired. + +- Replayed add with identical existing content returns an acknowledged no-op. +- Replayed add with different existing content rejects. It does not become an + upsert over newer work. +- Replayed patch whose desired content already matches returns an acknowledged + no-op before revision rejection. +- Replayed patch against different newer content rejects on revision. +- Replayed delete against a missing row returns an acknowledged no-op. +- Replayed reorder that already matches returns an acknowledged no-op. + +Prove each case in server tests. Operation-event lookup remains the fast path. + +## Execution path + +### 1. Lock the proof before the refactor + +Refresh the base branch and named branches. Run the baseline commands. Add +focused tests for the new contract before changing production behavior. + +The server tests must cover: + +- the shell excludes page content and canvas records; +- one page snapshot excludes other pages and their assets; +- the full snapshot reconstructs every page and referenced asset; +- a content op leaves the strategy row unchanged; +- different entities and different pages can update from the same starting + strategy revision; +- two edits to the same entity from one revision produce one visible reject; +- page membership and reorder use the strategy revision; +- every replay case above is safe after the event row is absent. + +The repository has no Convex test harness at the verified base. Add the +smallest repository-local harness that executes the real Convex functions and +schema. Keep pure helper tests only for logic that cannot run through the real +functions. + +Done when the new tests fail for the current broad-query, parent-write, and +memory-only behavior while the refreshed baseline still passes. + +### 2. Reshape the empty development deployment + +Update source schema and validators. Add `pageContents`, replace the strategy +content sequence with `strategies.revision`, and update operation events. +Update all page creation and cloud migration paths in the same cut. + +The development deployment contains team data only and may be wiped under the +rules in `AGENTS.md`. Verify that assumption before the destructive action. +Regenerate Convex output with `npx convex dev`. Edit no file under +`convex/_generated/` by hand. + +Done when a clean development deployment accepts the schema, page creation +always creates exactly one page content row, and `npx tsc --noEmit` passes. + +### 3. Add the narrow reads + +Implement the three target queries. Keep authorization in each public query. +Keep sorting deterministic. Use the existing canonical payload and asset +reference helpers instead of introducing a second serializer. + +Delete or deprecate `snapshot:get` only after all client and export callers +have moved. No subscribed provider may retain it as a recovery fallback. A +failure recovery may issue a one-shot full snapshot, then return to shell and +active-page subscriptions. + +Done when server tests prove the read sets by result and index path, and a +repository search shows the editor no longer subscribes to `snapshot:get`. + +### 4. Narrow the writes + +Refactor `ops:applyBatch` around the target write table. Split page descriptor +and page content intent. Apply replay checks before stale-revision rejection +where an identical desired state is a safe no-op. + +Keep one mutation batch atomic. Keep operation events as the acknowledgement +source. A rejected op returns the latest target revision and payload needed by +the current client conflict flow. + +Done when tests prove that content edits do not change the strategy row or an +inactive page, while metadata and page structure edits still update the shell. + +### 5. Split the Flutter read model + +Add repository methods for the shell, active page, and full snapshot. Replace +`remoteStrategySnapshotProvider` with a shell provider and one active-page +provider, or leave a thin compatibility name only if its type and lifetime are +unambiguous. + +On a page switch: + +1. Project and persist the current page intent. +2. Attempt a flush without making navigation wait indefinitely. +3. Change the active page ID. +4. Cancel the old page subscription. +5. Subscribe to the new page and hydrate its remote base. +6. Reapply any persisted local intent for that page. + +Replace `_RemotePageHydrationKey` sequence gating with active-page revision and +content fingerprints. A page snapshot update should rehydrate its page without +waiting for a strategy header update. Preserve undo history and the local +overlay rules already covered by provider tests. + +Export, recovery, and cloud-to-local copy paths must call the one-shot full +snapshot. Their types must prevent an active-page result from reaching export. + +Done when one editor owns exactly two live subscriptions regardless of page +count, inactive-page updates do not rehydrate the canvas, and every existing +page-switch, overlay, conflict, media, and round-trip test passes. + +### 6. Make the outbox durable + +Use a dedicated Hive box containing versioned, JSON-safe records. Keep it out +of `StrategyData` so local library migrations and `.ica` serialization remain +unchanged. Each record contains at least: + +```text +outboxVersion +accountId +strategyPublicId +entityKey +clientId +opId +serialized op +attempts +createdAt +updatedAt +lastAttemptAt +lastError +state +``` + +The account ID prevents one signed-in user from submitting another user's +pending work on a shared computer. Persist `clientId` and `opId` across app +restarts so a crash after server acceptance reuses the operation-event key. + +Use this ordering: + +1. Persist new or coalesced desired intent. +2. Reflect it in provider state and render `Syncing`. +3. Send it while the durable record remains present. +4. On acknowledgement, remove the durable record. +5. On rejection, persist a replacement intent before removing the rejected + record, or keep the rejected record in `Attention` for user action. + +Load and validate the box before the sync chip may say `Synced`. A corrupt +record produces `Attention` with recovery detail. Sign-out, strategy switch, +provider disposal, and retry exhaustion retain unacknowledged records. + +Replace the eight-attempt discard with a paused state. Manual retry resumes the +same durable intent. The exit guard reads durable state, including pending work +for a page that is no longer active. + +Tests must cover these crash windows: + +- restart after enqueue and before send; +- restart while an op is in flight; +- restart after server acceptance and before local removal; +- strategy switch with another page's pending op; +- auth expiry and recovery; +- retry exhaustion followed by manual retry; +- corrupt persisted record; +- sign-out and sign-in as the same account; +- sign-in as a different account. + +Done when no unacknowledged intent exists only in memory and retry exhaustion +cannot discard it. + +### 7. Run the automated gate + +Run focused tests while iterating, then run the full gate from the repository +root: + +```bash +npx tsc --noEmit +fvm flutter test \ + test/strategy_page_session_provider_test.dart \ + test/strategy_op_queue_provider_test.dart \ + test/collab_sync_models_test.dart \ + test/cloud_ui_parity_helpers_test.dart \ + test/strategy_integrity_test.dart +fvm flutter test +fvm flutter analyze --no-fatal-infos +git diff --check +``` + +If the Convex harness has a separate command, add it before the Flutter tests +and to CI. Record every command and result in the PR body. Existing warnings +may remain only when the refreshed base has the same warning. + +Done when the full gate passes and the diff contains no hand-edited generated +file, accidental lockfile churn, or unrelated worktree changes. + +### 8. Finish visible proof and PR delivery + +After the automated gate passes, read +[`server_side_sync_boundaries_delivery.md`](server_side_sync_boundaries_delivery.md). +It contains the required Computer Use scenarios, Convex resource measurement, +PR contents, and current-head review loop. Opening the PR before completing its +visible proof is premature. + +Done when every completion criterion in the delivery runbook passes. Leave the +PR unmerged for Dara. + +## Stop and escalate + +Stop implementation and ask Dara when any of these becomes true: + +- the linked Convex deployment contains public user data; +- the proposed server model would change local `.ica` or backup shape; +- another branch now implements the same boundary with incompatible choices; +- a generated-file change cannot be reproduced by its generator; +- preserving pending intent requires silently choosing between two users' + conflicting edits; +- the PR target has moved away from `icarus-cloud`; +- unrelated dirty work overlaps a file this refactor must rewrite. + +## File map + +Start here rather than scanning the whole app: + +```text +convex/schema.ts +convex/snapshot.ts +convex/ops.ts +convex/pages.ts +convex/strategies.ts +convex/lib/opTypes.ts +convex/lib/payloadValidators.ts +convex/maintenance.ts + +lib/collab/collab_models.dart +lib/collab/convex_strategy_repository.dart +lib/providers/collab/remote_strategy_snapshot_provider.dart +lib/providers/collab/active_page_live_sync_models.dart +lib/providers/collab/active_page_live_sync_provider.dart +lib/providers/collab/strategy_op_queue_provider.dart +lib/providers/collab/strategy_conflict_provider.dart +lib/providers/strategy_page_session_provider.dart +lib/strategy/strategy_page_source.dart +lib/strategy/strategy_import_export.dart +lib/widgets/cloud_sync_status_chip.dart +lib/services/unsaved_strategy_guard.dart + +test/strategy_page_session_provider_test.dart +test/strategy_op_queue_provider_test.dart +test/collab_sync_models_test.dart +test/strategy_integrity_test.dart +``` + +The blueprint explains why this boundary is the right one. This handoff defines +what must be true before the work is finished. diff --git a/docs/cloud_sync_refactor/typed_convex_wrapper_implementation_handoff.html b/docs/cloud_sync_refactor/typed_convex_wrapper_implementation_handoff.html new file mode 100644 index 00000000..d52f48be --- /dev/null +++ b/docs/cloud_sync_refactor/typed_convex_wrapper_implementation_handoff.html @@ -0,0 +1,959 @@ + + + + + + + + + Build the typed Convex wrapper in eight gated phases + + + + + + +
+
+
+ + ◆ PLAN + +
+
+
+ +
+ +
+
+

Handoff · Accepted · Plan only

+

Build the typed Convex wrapper in eight gated phases

+

Icarus will keep convex_flutter as its native runtime and own the typed layer above it. The implementation starts by repairing op identity, then makes protocol version 3 and every public return shape explicit before generating Dart. The target removes manual JSON plumbing, closes the native and web transport gap, and reduces folder reads from 2 live subscriptions to 1. This document authorizes planning and implementation on the remote machine, but it records no completed wrapper work.

+
+

Executive summary

+
+
8ordered phases
+
45public functions now
+
1 / 45return validators now
+
2 → 1folder subscriptions
+
+

Source: rg over convex/*.ts and the active Riverpod call sites at base commit be63971, captured 2026-08-29.

+

Build contract first. No generator work begins until op IDs name immutable work, protocol version 3 is closed and typed, and every client-callable Convex function has explicit argument and return validators.

+

The architecture is settled. The remote agent should make implementation choices only inside the boundaries below. If a boundary proves impossible, stop with a failing fixture and the smallest source trace that proves why. Do not weaken a type to keep moving.

+

Confidence is high for the protocol, generator, repository, and folder-tree shape. Confidence is medium for structured native errors and the exact function-spec scrubbing command until Phase 0 proves both against the pinned packages.

+
+
+

Read this before editing

+
    +
  1. Read ADR 0001. It owns architecture decisions. This handoff owns order and completion gates.
  2. +
  3. Read CONTEXT.md, AGENTS.md, and convex/_generated/ai/guidelines.md. Use the existing Icarus vocabulary.
  4. +
  5. Inspect git status before each phase. Preserve unrelated work and stage only the files named by that phase.
  6. +
  7. Edit source validators and models. Regenerate *.g.dart and convex/_generated/; never edit generated output by hand.
  8. +
  9. Keep each phase green before starting the next. A red completion gate starts the repair loop below.
  10. +
+
# Baseline checks before Phase 0
+git status --short
+git rev-parse HEAD
+fvm flutter pub get
+npm ci
+npx tsc --noEmit
+npm run test:convex
+fvm flutter analyze --no-fatal-infos
+fvm flutter test
+

The repository pins Flutter through .fvmrc. Use FVM for Dart and Flutter commands.

+

Use the dedicated test account

+

Use a disposable account provisioned at test time. Never commit or publish its credentials.

+
# Icarus test auth
+ICARUS_E2E_EMAIL=[provision at test time]
+ICARUS_E2E_PASSWORD=[provision at test time]
+
    +
  • Keep disposable test credentials outside Git and public artifacts.
  • +
  • Computer Use may read the values from private environment variables or a mode-600 temporary account file and enter them directly into the Icarus email and password fields.
  • +
  • Use this account only against the Icarus test Supabase project and its Convex deployment.
  • +
  • Take the first screenshot after the auth dialog closes so the receipt proves the signed-in product state.
  • +
+

A red gate means fix, prove, and continue

+
    +
  1. Freeze the smallest reproducible state. Save the seed, operation trace, failing fixture, logs, screenshots, platform, deployment, and commit that identify the failure.
  2. +
  3. Find the root cause. Fix it inside the accepted architecture. Do not weaken a type, skip a platform, loosen a comparison, or delete the failing fixture.
  4. +
  5. Add the smallest permanent regression test that goes red on the captured failure and green on the fix.
  6. +
  7. Run the focused test, the current phase gate, and every earlier gate the fix could affect. Rebuild before repeating a visible product check.
  8. +
  9. Resume from the earliest Computer Use step whose assumptions changed. Continue through the remaining phases and final matrix.
  10. +
+

done The issue is fixed only when its regression test passes, the affected broad gates pass, and the original reproduction no longer fails. Evidence is the start of the repair, not the end of the task.

+

Non-goals are equally firm. Do not replace Convex, adopt Dartvex, migrate to SpacetimeDB, redesign the UI, publish a general Dart package, change the local library schema, or change the .ica format. The cloud deployment and development outbox may be wiped because this branch has no cloud users. The local Hive library may not be wiped.

+
+
+

Fixed contract

+
+ + + + + + + + + + + + + + + + +
AreaAccepted shapeRejected shortcut
OwnershipIcarus owns the generator, generated API, codecs, and transport adapters.Depending on another package to define Icarus's public Dart contract.
TypingNo dynamic, Object?, raw map, or string enum crosses the wrapper boundary.Falling back to unknown types when generation cannot map a validator.
CoverageGenerate every client-callable public Convex function. Convert backend-only jobs to internal functions.Generating only the routes used today.
IDsPublic functions use Icarus public IDs as Dart String values. Convex document IDs stay on the server.Leaking v.id(...) because both representations happen to be strings.
ResultsClosed structural validators generate endpoint-scoped typed models. Repositories map them to Icarus models.Structural guessing or automatic cross-endpoint type deduplication.
PayloadsExact server tags bind canonical JSON envelopes to existing Icarus objects through annotated codecs.Wildcards, endpoint overrides, last-write-wins bindings, or duplicating every Flutter field in TypeScript.
QueriesOne ConvexQuery<T> exposes independent fetch() and watch(). A watch emits current data first.Fetching before watching or generating duplicate methods.
ErrorsTransport, timeout, decoding, and function failures stay distinct. Function errors preserve structured code, raw code, and message.Parsing human-readable error text.
OpsProtocol version 3 uses one closed discriminator such as element.patch. Known conflicts are rejected; unexpected failures are failed.Independent entity and action strings that can form illegal pairs.
OutboxSealed typed ops in memory, versioned primitive maps in Hive, strict reconstruction on load.Persisting generated request objects or assuming Hive object storage removes the wire format.
FoldersOne shared folders:listTree watch. Dart derives roots, children, paths, owned folders, and shared folders.A second parent query that rereads the same accessible tree.
DeliveryCommit the scrubbed function spec and generated Dart. CI regenerates both and rejects drift.Requiring a developer deployment for ordinary generation or trusting stale generated files.
+
+

Source: ADR 0001 and the accepted design review through 2026-08-29.

+
+
+

Target Dart shape

+

Authored Dart declares only domain codecs. The generator owns modules, methods, named parameters, enums, ordinary result models, serialization, and the private implementation.

+
# Authored binding syntax in lib/collab/convex_payload_codecs.dart
+@ConvexPayload('drawing')
+final class DrawingElementConvexCodec
+    implements ConvexPayloadCodec<DrawingElement> {
+  const DrawingElementConvexCodec();
+  @override
+  ConvexValue encode(DrawingElement value) => /* canonical envelope */;
+  @override
+  DrawingElement decode(ConvexValue value) => /* strict decoder */;
+}
+# Generated use inside a repository
+final query = api.folders.listTree();
+final currentTree = await query.fetch();
+final liveTree = query.watch();
+

The annotation plus generic codec follows normal Dart generator design. There is no runtime registration list. The generator scans this one configured library and fails on a missing, unused, or duplicate tag.

+
# Target source ownership
+convex/
+  function_spec.json                 # scrubbed and committed
+  error_codes.json                   # scrubbed server error catalog
+  lib/opTypes.ts                     # protocol v3 validators
+lib/collab/
+  convex_payload_codecs.dart         # authored exact tag bindings
+  generated/                         # generated standalone libraries only
+  transport/                         # normalized native and web adapters
+  repositories/                      # named Icarus methods and domain mapping
+tool/icarus_convex_codegen/
+  bin/generate.dart                  # deterministic Icarus-owned generator
+  lib/                               # parser, mapper, emitter, contract checks
+  test/                              # fixtures and golden failures
+ +

Source: target ownership and dependency direction from ADR 0001.

+
+
+

Execution order

+

Phase 0: prove the two uncertain seams

+

Keep this phase small. It produces fixtures and commands, not production abstractions.

+
    +
  • Run npx convex function-spec against the pinned Convex CLI. Capture stdout, identify deployment-specific fields, and prove that scrubbing then sorting produces byte-identical output twice.
  • +
  • Add a fixture with nested objects, unions, literals, optional fields, nulls, bytes, numbers, and a structured ConvexError.
  • +
  • Pass the fixture through the current native and web clients. Record the exact value and error data each transport exposes.
  • +
  • Keep the earlier Dartvex contract gate as evidence and fixture material. Do not add Dartvex to the app.
  • +
+

exit A committed fixture proves the function spec contains complete validators. A transport test either proves structured error parity or fails with the exact missing native or web field. The failure becomes a scoped fork task in Phase 5.

+

Phase 1: make op IDs immutable

+

Fix _mergeQueuedIntent before changing the protocol. The current implementation reuses existing.opId after payload, kind, target page, sort order, or expected revision changes.

+
    +
  • Keep an op ID only when _sameIntent says the work is unchanged.
  • +
  • Create a new UUID before persisting any merge that changes intended work. Preserve the client ID.
  • +
  • Write the replacement record durably before exposing it as queued state.
  • +
  • Cover add plus patch coalescing, patch replacement, reorder replacement, delete cancellation, restore, rejected retry, and process restart.
  • +
  • Add the lost-response regression: Convex applies op A, the response disappears, the client restores A, the user creates work B for the same entity, and the next flush applies B instead of replaying A.
  • +
+

exit No test permits changed work to retain an earlier op ID. Retrying byte-for-byte equivalent work keeps its ID. All outbox tests pass independently.

+

Phase 2: replace the generic op envelope with protocol version 3

+
    +
  • Replace kind plus entityType with a discriminated union keyed by one type literal such as element.patch.
  • +
  • Give every variant only its legal fields. Use names such as expectedPageRevision and expectedStrategyRevision when the guarded record differs.
  • +
  • Keep patch for element and lineup movement. Remove element.move and lineup.move. Retain a separate reorder operation only where ordering has distinct behavior.
  • +
  • Return a closed result union: applied, noop, rejected, or failed. Rejections carry typed reasons and an in-transaction typed current snapshot when needed.
  • +
  • Set clientProtocolVersion to 3. Set the new Hive outboxRecordVersion independently. Clear the development outbox and wipe the clay Convex deployment. Do not add a version 2 handler or pre-version-3 converter.
  • +
+

exit TypeScript cannot construct an illegal entity/action pair. Dart queue code exhaustively handles every request and result variant. Protocol mismatch is a structured failure. Convex and Dart protocol tests pass.

+

Phase 3: close the public Convex contract

+
    +
  • Audit all 45 current public functions. Convert backend-only maintenance and sweep functions to internal functions.
  • +
  • Add explicit argument and return validators to every remaining client-callable function. Use closed literals and unions for roles, statuses, result kinds, and error reasons.
  • +
  • Remove public v.id(...) values. Replace the image upload handshake with an Icarus upload-attempt public ID. Record any unavoidable infrastructure-handle exception explicitly in the generator allowlist and its test.
  • +
  • Change convex/lib/errors.ts to export one errorCodes constant and derive its ErrorCode type from that constant. Make every structured error helper accept only that type. The snapshot task writes the catalog to convex/error_codes.json.
  • +
  • Rename folders:listAll to folders:listTree. Delete folders:listForParent. The tree result includes the visible parent public ID and typed access role needed for local derivation.
  • +
  • Commit scrubbed convex/function_spec.json and convex/error_codes.json. Snapshot generation must remove deployment identity and produce stable ordering.
  • +
+

exit npx convex function-spec reports a non-null return schema for every client-callable function. The strict contract test finds zero v.any(), missing validators, unsupported validators, or unapproved public document IDs.

+

Phase 4: build the fail-closed generator

+
    +
  • Create the standalone Icarus tool under tool/icarus_convex_codegen/. Parse only the committed scrubbed spec during ordinary development.
  • +
  • Read convex/error_codes.json to emit the global ConvexErrorCode enum and its unknown case. Preserve an unknown raw server code on the exception.
  • +
  • Scan lib/collab/convex_payload_codecs.dart for @ConvexPayload codecs. Resolve each generic Dart type with the analyzer.
  • +
  • Generate the root factory, module interfaces, named parameters, ConvexQuery<T>, mutation and action methods, enums, unions, structural models, codecs, and the private implementation.
  • +
  • Recreate the owned output set under lib/collab/generated/ on every run. Format output. Run generation twice and require identical file paths and bytes.
  • +
  • Fail on missing or null validators, unknown nodes, v.any(), unbound opaque tags, unused or duplicate bindings, name collisions, public document IDs, warnings, or any public unknown type.
  • +
+
# Required developer workflow after the generator exists
+fvm dart run tool/icarus_convex_codegen/bin/generate.dart
+fvm dart format lib/collab/generated tool/icarus_convex_codegen
+git diff --exit-code -- convex/function_spec.json lib/collab/generated
+

exit Positive fixtures generate analyzable Dart. Every negative fixture fails for its intended reason. Renaming a function, argument, or result field breaks an unchanged typed caller at generation or analysis time.

+

Phase 5: normalize native and web transports

+
    +
  • Define one small private ConvexTransport interface and a closed normalized ConvexValue model.
  • +
  • Make query, mutation, action, and subscription results enter generated decoders through that model. Native JSON strings and JavaScript values may not reach a generated codec directly.
  • +
  • Preserve structured function error code, raw code, message, and data. Extend the vendored convex_flutter package if Phase 0 proves native data is missing.
  • +
  • Keep connection and authentication state on their existing streams. A recoverable function error does not close a subscription. A decoding or contract failure closes it.
  • +
  • Prove equivalent native and web values for null, omitted fields, numeric boundaries, bytes, nested objects, arrays, enums, errors, reconnects, and refreshed authentication.
  • +
+

exit The conformance suite sees identical normalized values and typed errors from both adapters. No production path parses an error message or calls jsonDecode on a generated result.

+

Phase 6: migrate repositories and collapse folder subscriptions

+
    +
  • Instantiate IcarusConvexApi with the platform transport. Keep generated imports inside the collaboration data layer.
  • +
  • Replace every string route and manual argument map in repositories, auth, migration, folder, strategy, share, media, and sync code with generated calls.
  • +
  • Map generated structural results to existing Icarus models inside named repository methods. Remove CloudFolderSummary, CloudStrategySummary, and other manual JSON DTOs only after their last caller moves.
  • +
  • Expose one shared repository folder-tree watch. Make Riverpod derive current children, roots, breadcrumbs, owned folders, and shared folders from that cached typed tree.
  • +
  • Keep strategy lists and active strategy snapshots separately scoped. Do not combine them with the folder tree.
  • +
+

exit Repository search finds no direct route strings outside transport or generated test fixtures. The cloud library holds one folder subscription and one current strategy-list subscription. Folder navigation, sharing, export, and editor sync tests pass.

+

Phase 7: lock drift out of CI and remove the old path

+
    +
  • Add an architecture test that rejects generated-client imports outside the allowed collaboration directory.
  • +
  • Add a CI job that deploys the current backend to an isolated Convex environment, captures and scrubs a fresh spec, and diffs it against convex/function_spec.json.
  • +
  • Run local generation from the committed spec and fail if it changes lib/collab/generated/.
  • +
  • Run the generated client tests and app tests on Windows CI. Add native build jobs for Windows and Linux so the wrapper cannot pass only on the macOS development machine.
  • +
  • Delete the legacy JSON decode helpers, generic StrategyOp envelope, stale DTOs, listForParent, and obsolete gauntlet-only assumptions. Keep the fair rerun artifacts as decision evidence.
  • +
  • Update docs/auth_flow_reference.md and any handoff that names deleted routes. Do not rewrite historical benchmark results.
  • +
+

exit A clean clone can regenerate the same API without a developer deployment. CI rejects a stale spec, stale output, public unknown type, direct route string, or forbidden import.

+
+
+

Test the contract from five directions

+
+ + + + + + + + + +
LayerWhat it must proveCompletion criterion
SchemaEvery public function has closed arguments, a closed return, allowed IDs, and a cataloged error path.The fresh isolated function spec matches the committed snapshot and every public function appears exactly once.
GeneratorEvery supported validator maps to strict Dart, and every unsupported shape stops generation.Positive, negative, golden, collision, mutation, stale-file, and deterministic fixtures all pass.
RuntimeNative and web transports deliver the same normalized values, errors, auth transitions, and subscription lifecycle.The same conformance corpus passes byte-for-byte normalized comparisons on both transports.
SyncProtocol version 3, revisions, immutable op IDs, persistence, replay, and user-visible status agree under faults.Seeded model tests and the 50,000-op gauntlet finish with no lost or duplicated work and no false synced state.
ProductA person can sign in, use the cloud library, edit on two independent clients, restart, and see the same data.Computer Use completes the release-build script below with timestamped visual receipts and zero manual reloads.
+
+

Source: ADR 0001, the corrected client gauntlet, cloud release gate, and current auth automation semantics.

+

Generator tests are a contract suite

+
    +
  • Unit-test every schema node the generator supports: null, boolean, integer, float, string, bytes, literal, optional, array, object, record, union, and recursive reference if the Convex spec can emit one.
  • +
  • Golden-test modules, escaped names, named parameters, enums, unions, structural results, query fetch() and watch(), mutations, actions, error enums, and exact payload bindings.
  • +
  • Negative-test missing and null validators, v.any(), unknown validator nodes, raw records at the public Dart boundary, unbound tags, unused bindings, duplicate bindings, route and type-name collisions, and unapproved public document IDs.
  • +
  • Mutation-test one function name, one argument name, one result field, one enum member, one payload tag, and one error code. Each mutation must either change generated output or fail generation. An unchanged typed caller must fail analysis after a breaking mutation.
  • +
  • Generate into a directory containing a stale owned file. The next run must delete it. Generate twice from shuffled but equivalent spec input. Both output trees must have the same paths and bytes.
  • +
  • Property-test codecs with fixed seeds. Every generated value that satisfies its validator must round-trip. Every value outside the validator must fail with the full endpoint and field path.
  • +
+

Sync tests cover every crash boundary

+
    +
  • Model every protocol request and result variant, including every rejected reason and unknown server error. Exhaustive switches may not use a default arm for known variants.
  • +
  • Crash after local persistence, after send but before response, after server apply but before receipt, while replacing queued intent, and while persisting a rejection. Restore from Hive after every crash point.
  • +
  • Exercise corrupt records, unsupported record versions, expired auth, rejected auth, reconnect, duplicate delivery, delayed delivery, out-of-order results, revision conflicts, delete and recreate, and subscription restart.
  • +
  • Run the deterministic gauntlet with 50 seeds and 1,000 operations per seed through two editing clients plus a fresh verifier. Keep the seed and trace for every failure.
  • +
  • Round-trip current and legacy .ica fixtures after the cloud cycle. Compare canonical data, not serialized key order. The local library before sign-in must remain byte-for-byte unchanged.
  • +
+
+
+

Final verification matrix

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
GateCommand or proofPass condition
TypeScriptnpx tsc --noEmitThe Convex source has zero type errors.
Protocolnpm run test:convexEvery version 3 request, result, rejection, auth, access-control, and revision case passes.
Authorizationconvex-test identities A, B, and C across every public function familyOwners, editors, viewers, unshared users, revoked users, and signed-out callers receive only their allowed data and operations.
Spec coverageFresh isolated npx convex function-spec plus strict auditEvery client-callable function has closed arguments and returns. The scrubbed snapshot has zero drift.
GeneratorGenerator unit, golden, negative, mutation, property, and collision testsPositive fixtures compile. Every unsupported or untyped fixture fails closed at the exact field path.
DeterminismGenerate twice from shuffled equivalent input, then git diff --exit-codeThe second run changes zero bytes and removes every stale owned file.
Breaking driftRename route, argument, result field, enum case, payload tag, and error code in fixturesEach mutation changes output, fails generation, or makes the unchanged typed caller fail analysis.
Dart typesfvm flutter analyze --no-fatal-infos plus generated-boundary auditNo analyzer error, dynamic, Object?, raw map, or untyped string enum crosses the wrapper.
CodecsFixed-seed valid and invalid value corpusValid values round-trip. Invalid values fail with endpoint and field path. Native and web bytes normalize identically.
Native bridgecargo test for changed Rust packages plus native conformance appStructured errors, auth refresh, reconnect, and subscription restart preserve their typed data.
Outbox modelFocused property and state-machine testsChanged intent always gets a new op ID. Equivalent retries keep one ID. Every state transition is legal.
Crash durabilityFive crash boundaries plus Hive restoreNo queued work disappears, no applied work is duplicated, and corrupt or unknown records fail visibly.
RepositoriesFocused auth, folder, strategy, share, media, migration, and sync integration testsEvery endpoint family uses generated calls and maps structural results to Icarus models.
Flutter behaviorfvm flutter testAll unit, widget, provider, semantics, and integration tests pass.
Round tripCurrent and legacy .ica import and export fixturesEvery fixture returns with no data loss. Signing in changes zero local-library bytes.
Web releasefvm flutter build web --no-wasm-dry-run --no-tree-shake-iconsThe release build succeeds through the web transport.
macOS releasefvm flutter build macosThe release app succeeds through convex_flutter.
Windows releaseWindows CI tests plus fvm flutter build windowsThe generated client, native package, full tests, and release build pass on Windows.
Linux releaseLinux CI tests plus fvm flutter build linuxThe generated client, native package, full tests, and release build pass on Linux.
Folder costCount live subscriptions during repeated navigationOne folder-tree subscription remains active. Navigation opens no parent subscription and performs no extra Convex call.
Gauntlet50 seeds, 1,000 operations per seed, two editors, one fresh verifierZero lost ops, duplicates, unresolved results, stale subscriptions, false synced states, or snapshot mismatches.
Performance10 paired before-and-after profile trials with alternating orderReport median and p95 convergence, reconnect, CPU, RSS, transfer, and bundle size. Investigate any median regression above 10% before merge.
Visible productComputer Use on macOS release plus web releaseThe full script below passes without manual reload, hidden errors, access-token leakage, or a false synced state.
+
+

Source: repository CI commands, the corrected client gauntlet, ADR 0001, and the implementation gates above.

+
# Final local gate on macOS after the new tool exists
+npx tsc --noEmit
+npm run test:convex
+(cd tool/icarus_convex_codegen && fvm dart pub get && fvm dart analyze && fvm dart test)
+fvm dart run tool/icarus_convex_codegen/bin/generate.dart
+fvm dart run tool/icarus_convex_codegen/bin/generate.dart
+git diff --exit-code -- convex/function_spec.json convex/error_codes.json lib/collab/generated
+fvm flutter analyze --no-fatal-infos
+fvm flutter test
+fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons
+fvm flutter build macos
+cargo test --manifest-path third_party/convex_flutter/rust/Cargo.toml
+cargo test --manifest-path third_party/convex_rs/Cargo.toml
+git status --short
+

The final status may contain only the intended source, snapshot, generated output, tests, and documentation for this implementation.

+

Computer Use gate: prove the built product

+

This is a release-build acceptance run, not a demo against mocked providers. Use the final macOS app and the final web build against the same test deployment. The two clients may share the supplied account, but they must not share local storage.

+
    +
  1. Record the commit SHA, Convex deployment, Supabase project, app version, UTC start time, macOS version, and browser version. Create a unique run name such as typed-wrapper-e2e-20260829T143000Z.
  2. +
  3. Launch build/macos/Build/Products/Release/icarus.app through Computer Use. Start signed out. Count the local library and export one known local strategy. Keep that export for the final byte comparison.
  4. +
  5. Open the login dialog. Enter the email and password from the test-auth block, then submit. Decline any password-save prompt.
  6. +
  7. After the dialog closes, capture the first screenshot. Confirm Cloud becomes available and the signed-in account indicator appears. Confirm the local library count and known strategy remain unchanged.
  8. +
  9. In Cloud, create a top-level folder named with the run name, create one child folder, and create a strategy inside it. Add two pages, an element, and a lineup. Wait for the visible sync chip to say Synced.
  10. +
  11. Serve the release web build and open it in a clean browser profile. Sign in with the same test credentials. Without reloading, confirm the folder tree, child folder, strategy, pages, element, and lineup appear.
  12. +
  13. Rename the child folder in web. Move the strategy between the two run folders. Patch the element and lineup positions. Confirm macOS receives each change without a reload and keeps the correct breadcrumb.
  14. +
  15. Edit the same strategy from both clients. Exercise an accepted edit and an intentional stale-revision edit. The accepted edit must converge. The rejected edit must produce the documented visible state and must never flash Synced.
  16. +
  17. Close the macOS app after it reaches Synced. Reopen it through Computer Use and confirm the complete cloud state. Sign out, sign in again, and confirm the same state one more time.
  18. +
  19. Export the cloud strategy, import it into local mode under a new name, and compare its canonical content with the cloud copy. Compare the original local export from step 2 with a fresh export. Both comparisons must lose zero supported data.
  20. +
  21. Review the accessibility tree after every navigation or asynchronous state change. Use fresh element indices. If a required control is missing, add a stable semantic label and widget test, rebuild, and restart this run.
  22. +
  23. Capture screenshots after sign-in, initial sync, cross-client rename and move, accepted edit, rejected edit, restart readback, and round trip. Record timestamps so the receipts can be matched to logs and server events.
  24. +
  25. After evidence is saved, delete only the cloud folder whose exact name starts with this run name. Keep the original local library untouched. Record cleanup success in the receipt.
  26. +
+

The supplied credential proves same-account sync across independent clients. It does not prove cross-account sharing or revocation. The automated authorization matrix remains mandatory unless Dara supplies separate live accounts B and C. Do not create accounts to make the live script look complete.

+

red loop Pause the run on data loss, an access-control leak, a reconnect loop, a stale subscription, an unreadable record, or any moment where Synced is shown while work is still local, rejected, failed, or uncertain. Preserve the run data and logs, fix the root cause, add a regression test, rebuild, and restart at the earliest affected step. Then finish the script.

+

Source: cloud release gate, auth automation semantics, current sync-status keys, and the Computer Use workflow.

+

Release-candidate receipt: 2026-08-30

+

internal RC The contract, restart, and Windows local-library lifecycle boundaries are green. Outside invitations remain paused until one visible two-client run covers UI write, offline restart, rejection, media round trip, sign-out, and sign-in.

+
+ + + + + + + + + + + + +
BoundaryCurrent evidenceStatus
Convex sourcenpx tsc --noEmit; 30 Convex tests; 42 public functions and 34 error codes audited; scrubbed contract snapshot current.green
A/B/C accessPrivate isolation, viewer denial, editor round trip, revoked-link denial, durable redeemed access, inherited folder roles, and Strategy access-record cleanup. The deployed run used synthetic identities; two fresh Supabase accounts separately proved real password JWT to Convex identity mapping.green contract
Durable recovery388 Flutter tests pass with one expected Windows probe skip. They cover enqueue-before-send restart, in-flight replay, retry exhaustion, manual retry identity, rejected reconciliation, auth setup incidents, and local fallback when Convex is unavailable.green model
R2Live presign, upload, public byte readback, tampered-key rejection, missing-object rejection, and cleanup all passed. Built-app upload queue restart and export remain a visible product gate.partial product
macOS productAn isolated 4.3.7 release app reconnected persisted auth, received a cloud Strategy through its subscription, retained it across a full process restart, received a live server rename without reload, and opened the editor with Synced visible.green read path
Computer Use inputThe Flutter macOS name field accepted focus but rejected native keyboard and clipboard injection. The Strategy was created through the authenticated backend. Do not count this receipt as a native UI-create proof.tooling limit
Web productThe 2026-08-25 production web receipt proves real sign-in, Convex write, second-page edit to Synced, reload, and readback. The current collaborative-browser semantics snapshot timed out.older receipt
Windows installerRun 33296220156 passed for source 57f4acb26f13ef4c47773f750949b11b68e7a272. It installed public 3.2.3+42, imported the pinned v35 Strategy, upgraded to candidate 4.3.7+91, and rolled back to 3.2.3+42. Every stage retained the same Strategy identities, normalized semantics, 3,449-byte Hive file, and SHA-256 7e27b9594478c9aa85a550944d58d95c415875a13ac010f7bf5d3777c003e133. The private artifact is icarus-windows-rc-57f4acb26f13ef4c47773f750949b11b68e7a272; it is CI evidence, not a published release.green local lifecycle
+
+

The candidate branch started from icarus-cloud commit ce9beec189e35f48cced623e161aa55f2642e782. The merge decision uses the current pull-request head after all required checks are green. On pull requests every CI job explicitly checks out that source SHA rather than GitHub's temporary merge commit. The artifact is named icarus-windows-rc-<source-sha>, so its installer and receipt cannot be confused with another candidate. No Windows tag, GitHub Release, updater entry, or public installer is produced by this gate.

+
# Exact local receipt
+npx tsc --noEmit
+npm run test:convex
+npm run snapshot:convex-contract:check
+npm run audit:convex-contract
+fvm flutter analyze --no-fatal-infos
+fvm flutter test
+fvm flutter build web --no-wasm-dry-run --no-tree-shake-icons
+fvm flutter build macos --no-tree-shake-icons
+
+# Exact Windows CI receipt
+powershell -ExecutionPolicy Bypass -File installer/build_installer.ps1 -Configuration Release
+powershell -ExecutionPolicy Bypass -File scripts/test_online_beta_windows_upgrade.ps1 `
+  -ReleaseCandidateInstaller build\installer\icarus-setup-<version>.exe
+

Resume path for the next agent: read AGENTS.md, this receipt, and docs/cloud_online_release_gaps.md; fetch the pull request; verify local HEAD equals the remote head; inspect the Windows JSON artifact; replay only the unchecked visible product steps; update the checklist with evidence; then babysit checks, bot reviews, and unresolved threads on the current SHA before merge.

+
+
+

Commit boundaries and stop conditions

+
+ + + + + + + + + + + +
CommitContainsMust be green before push
1Immutable op-ID repair and regression testsFocused outbox tests and Flutter analysis
2Protocol version 3, development wipe path, typed outcomesConvex protocol tests and Dart sync tests
3Public validators, internalized jobs, scrubbed specConvex tests and strict spec audit
4Generator, bindings, generated API, contract fixturesGenerator tests, deterministic regeneration, analysis
5Normalized transports and any required fork extensionNative and web conformance suite
6Repository migration, one folder tree, DTO removalFlutter tests, web build, macOS build
7CI drift gates, architecture test, reference cleanupFull final verification matrix
+
+

Source: dependency order in ADR 0001. Each commit is reviewable and leaves the branch buildable.

+

Use one draft PR with reviewable phase commits

+

Use one pull request, not a stack. The protocol, function spec, generator, transports, and repository migration describe one contract. A stacked review would make each earlier PR expose a temporary shape, repeat generated-code review, and force every later branch to rebase after a contract fix.

+
    +
  1. Open one draft PR after the first green phase commit so Windows, Linux, and macOS CI can run throughout implementation. Keep the seven phase commits separate and buildable.
  2. +
  3. Push after each phase. Treat CI failures with the same evidence, fix, regression, rerun loop. Do not request final review while a phase or platform is red.
  4. +
  5. Write the PR description as a map: accepted architecture, phase commits, generated files, protocol change, test matrix, Computer Use receipts, performance comparison, and known non-goals.
  6. +
  7. After all seven phases and the full final matrix pass, mark the PR ready and trigger Greptile. Old draft reviews do not count unless they apply to the current head SHA.
  8. +
  9. For each actionable Greptile finding, verify it against current source, fix it, add or strengthen a regression test, run focused and broad gates, commit, push, and request a fresh review with @greptileai.
  10. +
  11. Poll the current SHA until every required check is green, Greptile reports 5/5 or no actionable findings, and a thread-aware read finds no unresolved actionable review thread. A summary comment alone is not proof.
  12. +
  13. Finish with a clean local branch whose SHA matches the PR head. Report the PR URL, final SHA, commits added during review, every verification result, and any skipped gate with its exact blocker.
  14. +
+

decision One large diff is the cost. Phase commits, a file map, and receipts make it reviewable without paying the larger coordination cost of a PR stack.

+

Use the repair loop for ordinary failures. Ask Dara only when one of these blocks progress:

+
    +
  • The only path forward would weaken an accepted contract or change a user-selected design decision.
  • +
  • The fix would change the local Hive schema, require a local migration, or change the .ica format. Those are outside this plan.
  • +
  • A required deployment permission, signing identity, or supported-platform runner is unavailable after safe in-scope alternatives are exhausted.
  • +
  • Two current requirements conflict and the repository does not contain evidence that resolves the conflict.
  • +
+
+
+

Source inventory

+
+ Current seams the implementation must retire or preserve +
# Current manual boundary
+lib/collab/convex_strategy_repository.dart
+lib/collab/collab_models.dart
+lib/collab/convex_client.dart
+lib/collab/src/convex_client_native.dart
+lib/collab/src/convex_client_web.dart
+# Durable sync and replay
+lib/collab/durable_strategy_outbox.dart
+lib/providers/collab/strategy_op_queue_provider.dart
+lib/providers/collab/active_page_live_sync_models.dart
+convex/ops.ts
+convex/lib/opTypes.ts
+# Folder subscription collapse
+convex/folders.ts
+lib/providers/collab/remote_library_provider.dart
+lib/widgets/folder_content.dart
+lib/widgets/folder_navigator_sidebar.dart
+lib/widgets/current_path_bar.dart
+# Contract and runtime evidence
+tool/convex_client_gauntlet/
+docs/cloud_sync_refactor/convex_dart_client_fair_rerun_result.md
+

Repository audit at be63971 on 2026-08-29.

+
+
+ Decision evidence and reusable fixtures + +
+
+

Generated 2026-08-29 · Codex with Dara's accepted design decisions · repository base be63971 · verification expansion based on 2191788 · source audit on macOS · revision v3 · implementation not started

+
+
+
+ + + diff --git a/docs/cloudflare_r2_media_storage.md b/docs/cloudflare_r2_media_storage.md new file mode 100644 index 00000000..30679697 --- /dev/null +++ b/docs/cloudflare_r2_media_storage.md @@ -0,0 +1,88 @@ +# Cloudflare R2 Media Storage + +Icarus stores image ownership, strategy access, references, cleanup state, and URL generation in Convex. Cloudflare R2 stores only image bytes. + +## Required Convex Environment + +- `R2_ACCOUNT_ID`: Cloudflare account ID for the R2 S3 API endpoint. +- `R2_BUCKET`: bucket name. +- `R2_ACCESS_KEY_ID`: R2 S3 API token access key. +- `R2_SECRET_ACCESS_KEY`: R2 S3 API token secret. +- `R2_PUBLIC_BASE_URL`: public custom-domain base URL used for active R2-backed reads, for example `https://media.example.com`. +- `R2_S3_ENDPOINT`: optional override for the S3 endpoint. Defaults to `https://.r2.cloudflarestorage.com`. +- `R2_UPLOAD_URL_EXPIRES_SECONDS`: optional signed PUT URL lifetime. Defaults to 900 seconds. +- `R2_MAX_IMAGE_BYTES`: optional max image size. Defaults to 15728640 bytes. + +New uploads fail with an actionable Convex error if the required R2 env vars are missing. Legacy Convex-storage assets remain readable through `ctx.storage.getUrl(storageId)`. + +## Runtime Flow + +1. The client calls `images:generateUploadUrl` with strategy ID, asset ID, MIME type, extension, and byte size. +2. Convex checks editor access, inserts a pending `imageAssets` row, creates a high-entropy immutable R2 object key, and returns a short-lived signed PUT URL. +3. The client uploads bytes directly to R2 with the signed `Content-Type` header. +4. The client calls `images:completeUpload` with the upload intent metadata. +5. Convex verifies the R2 object exists, checks size and MIME metadata, and marks the row active. Replaced objects become durable deletion tombstones and are removed by the cleanup worker. + +Strategy/page/lineup payloads store image IDs and local metadata only. Public render URLs are returned from `images:listForStrategy` and `images:getAssetUrl`; they are not persisted in strategy payloads. + +## Edge Cases + +- Expired upload URL: the client does not persist the signed URL. A retry requests a fresh pending upload intent. +- MIME mismatch: `Content-Type` is signed for PUT and completion verifies R2 metadata against the file extension. +- Oversized image: completion rejects and deletes the uploaded R2 object if it exceeds `R2_MAX_IMAGE_BYTES`. +- PUT succeeds but completion fails: the pending row and object key remain available for retry. The hourly `mark-stale-image-uploads-deleted` job reclaims old pending and failed uploads without user authentication. +- Content lands before its upload: a placed image or lineup image and its upload reach Convex independently. When `ops:applyBatch` accepts content that newly shows an image with no asset row, it inserts a pending placeholder row (no object key). Readers then see the image as on its way rather than missing, and a duplicate waits for it. `images:generateUploadUrl` adopts the placeholder instead of adding a second row. Deleting the image element drops its placeholder unless a lineup still shows that image (no other element can: a placed image's id is its element's own id). Restoring a deleted element (undo) expects its image again only if the element was first placed within the stale-upload window (24h); an older one would have uploaded or been swept by then. A placeholder whose upload never comes is swept with other stale pending rows, after which the image shows as unavailable. Moving the image later does not bring the placeholder back. +- Pending upload never completed: pending/failed rows are indexed by `uploadStatus` and `updatedAt`. The cron job marks them for cleanup after 24 hours. +- Replacing an asset: the new immutable R2 object is activated before older active rows for the same strategy asset ID are marked deleted. +- Page and Strategy deletion: page cleanup only marks strategy-owned assets that no remaining Page or Lineup references. Strategy cleanup marks every asset with that Strategy's exact Convex ID and leaves legacy rows without a `strategyId` alone. +- Strategy duplication: `strategies:duplicate` gives the copy its own active `imageAssets` row for each image it shows, pointing at the same R2 object key (or Convex storage file) as the original's. Each row is one reference: the cleanup worker removes a row whose bytes another row still points at without touching the object, and deletes the object only with the last row. Deleting either strategy leaves the other's images intact. A duplicate is refused while one of its images is still pending upload, so the copy never permanently lacks an image that was about to land. +- R2 deletion fails: the deleted `imageAssets` row keeps its object key. The worker retries after one minute, and the hourly sweep provides a second recovery path. A 404 counts as success, so retries are safe. +- Duplicate upload attempts: each upload intent gets a unique object key; completion is tied to its `uploadId`. +- Legacy dev data: rows with `storageId` and no R2 provider are treated as active Convex-storage assets. +- Strategy access revoked: Convex stops returning URLs to unauthorized viewers, but already-copied public custom-domain URLs can remain reachable until the object is deleted or Cloudflare access controls/cache expire. +- Custom domain disabled or misconfigured: R2-backed reads require `R2_PUBLIC_BASE_URL` to point at an enabled public bucket custom domain. +- CDN stale copies: object keys are immutable, so replacements use fresh URLs. Deleted old URLs may remain in Cloudflare cache until normal invalidation/expiry unless purged separately. +- Flutter web: the bucket needs the CORS rule under "Web beta" below, or browser uploads and reads fail. + +## Web beta + +**Required dashboard step. Applied on the `icarus-media` bucket; any new bucket needs it too.** The web beta adds images and lineups from the browser, so the browser talks to R2 directly: it PUTs bytes to the presigned S3 URL (`https://.r2.cloudflarestorage.com/...`) and GETs them from `R2_PUBLIC_BASE_URL` to paint them. Both are cross-origin, so the bucket must allow the web beta origins. Without this rule the upload's preflight fails, the media job retries and stays unsynced, and images the browser cannot fetch fall back to a plain `` element. + +In the Cloudflare dashboard: R2 > the media bucket > Settings > CORS Policy > Add CORS policy, and paste: + +```json +[ + { + "AllowedOrigins": [ + "https://beta.icarusstrats.com", + "https://icarus-web-a50.pages.dev", + "http://localhost:8765", + "http://127.0.0.1:8765" + ], + "AllowedMethods": ["PUT", "GET", "HEAD"], + "AllowedHeaders": ["Content-Type"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 3600 + } +] +``` + +- `PUT` with `Content-Type` is the presigned upload; `Content-Type` is the only header the client sends and the only one signed besides `host`. +- `GET`/`HEAD` let the web app fetch images from the public custom domain to paint them. +- `ETag` is exposed so the client can pass it to `images:completeUpload`. Completion reads the ETag from R2 itself first, so this is informational. +- Desktop is not a browser and ignores CORS; this rule changes nothing for it. +- Local web builds must run on port 8765 to upload images: `flutter run -d chrome --web-port 8765`, or serve `build/web` on 8765. R2 rejects a port wildcard such as `http://localhost:*`, so the rule lists `localhost` and `127.0.0.1` on that one port. Any other local port has no image uploads. + +While an upload is pending, the browser keeps the image bytes in IndexedDB (Hive box `pending_media_bytes_box`) so a refresh does not lose them. Records are scoped to account, strategy and asset, like the upload job, and are dropped once the upload is attached. Images over 15 MB (the `R2_MAX_IMAGE_BYTES` default) are refused before anything is stored. Bytes picked for a lineup that was never saved are dropped when the image is removed or the dialog is closed, and the dialog cannot be closed while Save is queuing them. Drafts older than 7 days with no upload job are removed at launch; a lineup dialog left open that long in another tab loses its picked images. + +Known limits: +- Records written by the unreleased first version of this box (raw bytes keyed by asset ID, commit `d191ffe`) are unreadable by the current decoder and are left in place, never pruned. That version never shipped, so only development browsers can hold them; clear the site's IndexedDB to remove them. +- Desktop refuses an image over 15 MB at pick in a cloud strategy, as web does. A local strategy takes any size, since it never uploads. +- A duplicate made after a placeholder was swept copies no image. If the source's upload lands later, the copy never gets it. +- Completion retry window: if `images:completeUpload` keeps failing after the PUT succeeded, the image stays pending (a spinner everywhere) until a retry completes it. After 24h the stale sweep can reclaim the row and its object even while the client is still retrying; the retry then fails, and the image reads as unavailable. + +Cloudflare references: + +- https://developers.cloudflare.com/r2/api/s3/presigned-urls/ +- https://developers.cloudflare.com/r2/api/s3/api/ +- https://developers.cloudflare.com/r2/data-access/public-buckets/ diff --git a/docs/mockups/library-nav-alternatives.html b/docs/mockups/library-nav-alternatives.html new file mode 100644 index 00000000..0f34ed9c --- /dev/null +++ b/docs/mockups/library-nav-alternatives.html @@ -0,0 +1,426 @@ + + + + +Icarus library navigation: five alternatives + + + +

Icarus library navigation · · press 1–5 (b for 3b) or ←/→ to switch

+
+ +
+
+
+
+ + + + diff --git a/docs/release_process.md b/docs/release_process.md index 0c3e801c..b5dc10a4 100644 --- a/docs/release_process.md +++ b/docs/release_process.md @@ -19,21 +19,161 @@ Keep them separate. Run the workflow for the channel you actually want to publis ## Before Any Release -1. Make sure the branch contains the changes you want to ship. +1. Check the branch. Stable desktop and every Store build must run from + `main`. The release scripts stop before a version bump or build on any other + branch. The scripts still build an unsigned desktop prerelease from a + feature branch for local testing, but the signed `Release Desktop` workflow + runs only from `main`. 2. Run the focused validation locally: - `fvm flutter test test/update_checker_test.dart` + - `fvm flutter test test/cloud_build_config_test.dart` + - `powershell -ExecutionPolicy Bypass -File scripts/test_release_safety.ps1` - `fvm flutter analyze` 3. Check `pubspec.yaml` and confirm the version you want to release. 4. Create or update the matching release metadata file in `release/metadata/`. 5. Write player-facing release notes in that metadata file. +## Cloud build configuration + +Icarus has one named development Convex configuration in source. Local +development, CI, and desktop prerelease builds select it with +`ICARUS_CLOUD_ENVIRONMENT=development`. + +An ordinary debug run defaults to development. A release-mode app with no +`ICARUS_CLOUD_ENVIRONMENT` stops during startup, so any new release entry point +must choose `development` or `production` deliberately. + +Stable desktop and Store builds select `production` and require both of these +GitHub repository variables: + +- `ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL` +- `ICARUS_PRODUCTION_CONVEX_CLIENT_ID` + +The production URL and client ID are public build inputs, not deploy keys. The +release scripts pass them to Flutter through a temporary Dart-defines file and +delete that file after the build. A missing value, invalid URL, or the known +development deployment stops the release before Flutter runs. + +Use the deployment's canonical `https://.convex.cloud` client URL. +The release validator does not accept custom domains, and `.convex.site` is the +HTTP Actions URL rather than the client deployment URL. See Convex's +[deployment URL guide](https://docs.convex.dev/client/react/deployment-urls) +and [system environment URL definitions](https://docs.convex.dev/production/environment-variables). + +Stable desktop, Store, and production backend workflows all enter the protected +GitHub `Production` environment before they can build or publish. Desktop +prerelease skips that environment. + +For a local stable build, set the same two environment variables in the shell +before running `scripts/release_desktop.ps1`. Never put a Convex deploy key in a +Dart define or repository variable. + +## One-time production Convex setup + +No production deployment or key is checked into this repository. Before the +first production release: + +1. Create or select the Icarus production deployment in Convex. Record its + `.convex.cloud` client URL in the repository variable above. +2. Create a deployment-scoped production deploy key with only the permissions + needed to deploy. Convex supports this in the deployment settings or with + `npx convex deployment token create github-production --deployment prod`. + See the [Convex deploy-key documentation](https://docs.convex.dev/cli/deploy-key-types). +3. Create a GitHub environment named `Production`. Restrict its deployment + branches to `main`, add any required reviewers, and add the secret + `CONVEX_PRODUCTION_DEPLOY_KEY`. +4. Add the public production URL and a stable client identifier, such as the + identifier chosen for the shipped Icarus client, to the two GitHub repository + variables in the previous section. +5. Configure the production deployment's required R2 environment values before + testing cloud media. The backend reports the exact missing names if they are + absent. + +Run the manual `Deploy Convex Production` workflow from `main` and type +`deploy-production`. The workflow enters the GitHub `Production` environment, +requires a `prod:` deploy key, installs locked dependencies, runs TypeScript and +Convex tests, then runs `npx convex deploy --typecheck enable`. Convex documents +that `CONVEX_DEPLOY_KEY` selects the deployment associated with that key. See +the [`convex deploy` reference](https://docs.convex.dev/cli/reference/deploy). + +The production workflow never reads `CONVEX_PREVIEW_DEPLOY_KEY`. That secret is +only for the isolated contract deployment in CI. + +Each `convex-contract` run creates one preview deployment named +`typed-wrapper-contract-` and deletes it in its last step, +through the Convex Management API (`POST /v1/deployments//delete`) with +the same preview deploy key. Right after creating it, the job also sets the +preview to expire in one hour, which covers runs that die before the delete +step. Previews count against the team's deployment quota (40), and when the +quota is full every PR fails with `DeploymentQuotaReached`. To clear leftovers +by hand, list the project's deployments with +`GET /v1/projects//list_deployments` using the token in +`~/.convex/config.json`. Then delete only `preview` deployments whose PR is +closed. Never delete `majestic-eel-413` (dev) or the production deployment. + +## Web beta deploy + +The web beta lives at `https://beta.icarusstrats.com`, served by the Cloudflare +Pages project `icarus-web` (also reachable at `https://icarus-web-a50.pages.dev`). +It runs against the **production** Convex deployment (`basic-dove-69`), because +it is public. The development deployment (`majestic-eel-413`) stays the place +to try server changes by hand; never test on production. + +- A push to `main` that changes `lib/`, `web/`, `assets/`, `shaders/`, + `convex/`, a path package (`packages/`, `third_party/convex_flutter/`), + `pubspec.yaml`, `pubspec.lock`, `package.json`, `package-lock.json`, or + `.fvmrc` deploys automatically: first the Convex server to production, then + the web build. If the server deploy fails, nothing is published. +- Because production updates on merge, a PR that changes the Convex contract + must stay compatible with the web build already live (additive first; see + the deploy order in each server PR). +- To redeploy by hand: `Actions` > `Deploy Web` > `Run workflow` on + `main`. +- The run summary links the deployment. To roll back, promote an earlier + deployment in the Cloudflare dashboard under the project's `Deployments`. + +The workflow builds with the same flags as CI's `Build Web Client` step, but +with `ICARUS_CLOUD_ENVIRONMENT=production` and the production URL and client ID +from the repository variables, and uploads `build/web` with `wrangler pages deploy --branch=main`. `main` is the +Pages project's production branch, and the custom domain follows production. +Share links and the auth callback load because Pages serves `index.html` for +unknown paths whenever `build/web` has no top-level `404.html`, so never add +one. `web/_headers` makes browsers revalidate Flutter's unhashed entry +files, so testers get a new deploy on refresh. + +GitHub repository secrets: + +- `CLOUDFLARE_API_TOKEN`: the Cloudflare API token `icarus-web-pages-deploy`, + scoped to the Icarus account with `Account` > `Cloudflare Pages` > `Edit` + (the web build) and `Account` > `Workers Scripts` > `Edit` (the presence + Worker, `presence/README.md`). +- `CLOUDFLARE_ACCOUNT_ID`: the account ID shown on the account's Workers & Pages + overview. +- `CONVEX_PRODUCTION_DEPLOY_KEY`: a `prod:` deploy key for `basic-dove-69` + (`npx convex deployment token create --prod`). + +GitHub repository variables: `ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL` +(`https://basic-dove-69.convex.cloud`) and `ICARUS_PRODUCTION_CONVEX_CLIENT_ID` +(`prod:basic-dove-69`). The production deployment needs the same R2 +environment values as development (`npx convex env list --prod`). + +One-time Cloudflare setup: + +1. Create the direct-upload project with production branch `main`: + `npx wrangler pages project create icarus-web --production-branch=main` + (after `npx wrangler login`). The dashboard path is `Workers & Pages` > + `Create` > `Pages` > `Upload assets`, named `icarus-web`. +2. In the project, open `Custom domains` > `Set up a custom domain`, enter + `beta.icarusstrats.com`, and activate it. Cloudflare adds the DNS record when + `icarusstrats.com` is on the same account. + ## Desktop Release Checklist Use this when you want to publish the direct installer channel. 1. Go to `Actions` in GitHub. 2. Open `Release Desktop`. -3. Click `Run workflow` and select `main`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `channel`: `stable` @@ -82,7 +222,7 @@ Use this when you want to publish the Microsoft Store channel. 1. Go to `Actions` in GitHub. 2. Open `Release Store`. -3. Click `Run workflow`. +3. Confirm the selected branch is `main`, then click `Run workflow`. 4. Choose: - `version_bump`: `none` if the version is already correct, otherwise `patch`, `minor`, or `major` - `publish_to_store`: `false` for a dry run, `true` when you are ready to submit @@ -117,6 +257,9 @@ Use this when you want to publish the Microsoft Store channel. - The shared scripts verify EXE and DLL signatures before packaging, staging, and pushing Pages content. Manual phased releases require signing between build and package, then signing the installer before stage. - GitHub Pages should be configured to serve `gh-pages` from `/ (root)`. - No extra Pages deploy workflow is needed for prerelease testing. +- `release/metadata/4.6.1+97.json` is prerelease-only while the online beta + checks remain open. Do not add `stable` to its channels to make a stable + manifest build pass. - Direct desktop installs now use a per-user install path and per-user registry registration. - Store installs should continue to use the Microsoft Store update path only. - The metadata file should not be a generic `template.json` in the live metadata folder, because the manifest generator treats every JSON file there as a real release entry. diff --git a/lib/collab/canonical_json.dart b/lib/collab/canonical_json.dart new file mode 100644 index 00000000..e6fa567c --- /dev/null +++ b/lib/collab/canonical_json.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; + +Object? canonicalCloudJsonValue(Object? value) { + if (value is Map) { + final entries = value.entries + .map((entry) => MapEntry(entry.key.toString(), entry.value)) + .toList(growable: false) + ..sort((a, b) => a.key.compareTo(b.key)); + return { + for (final entry in entries) + entry.key: canonicalCloudJsonValue(entry.value), + }; + } + if (value is List) { + return value.map(canonicalCloudJsonValue).toList(growable: false); + } + if (value is num && value.isFinite) { + return value.toDouble(); + } + return value; +} + +String canonicalCloudJsonEncode(Object? value) { + return jsonEncode(canonicalCloudJsonValue(value)); +} + +bool cloudJsonEquivalent(Object? left, Object? right) { + if (identical(left, right)) { + return true; + } + return canonicalCloudJsonEncode(left) == canonicalCloudJsonEncode(right); +} diff --git a/lib/collab/cloud_library_models.dart b/lib/collab/cloud_library_models.dart new file mode 100644 index 00000000..1b329db3 --- /dev/null +++ b/lib/collab/cloud_library_models.dart @@ -0,0 +1,22 @@ +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +/// A cloud folder plus what the server says it holds across its subtree: +/// strategy count, the two most-used maps, and the agents in play. The +/// client never loads a folder's strategies to draw its card. +typedef CloudFolderEntry = ({ + Folder folder, + String role, + int strategyCount, + List mapPeeks, + List agentTypes, +}); + +typedef CloudStrategyEntry = ({ + StrategyData strategy, + int revision, + String role, + String attackLabel, +}); diff --git a/lib/collab/cloud_lineup_rows.dart b/lib/collab/cloud_lineup_rows.dart new file mode 100644 index 00000000..b1e41aaf --- /dev/null +++ b/lib/collab/cloud_lineup_rows.dart @@ -0,0 +1,315 @@ +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// One cloud row per lineup group: lineups that share a standing spot or a +/// landing spot, stored together with those spots. +/// +/// A shared spot lives in its group's row, so it cannot disagree with +/// itself, and the row's one revision guards every lineup on it: two +/// clients changing the same group meet as an ordinary conflict. A row never +/// names anything outside itself. +/// +/// A lineup stays in the group it was first written to: deleting the lineup +/// that joined two halves leaves both halves in the row, and groups never +/// merge. That keeps every edit a write to exactly the row the user's +/// lineups already live in, and no row is ever deleted except by deleting +/// its last lineup. +/// +/// No two live rows of a page hold the same lineup or spot: the server +/// refuses a write that would make them (see assertLineupGroupAlone in +/// convex/ops.ts), so the work waits in attention rather than two rows +/// disagreeing about one spot. +class CloudLineupRow { + const CloudLineupRow({required this.publicId, required this.payload}); + + CloudLineupRow.remote(RemoteLineup lineup) + : this(publicId: lineup.publicId, payload: lineup.payload); + + /// The group's id, as in `payload.data.id`. + final String publicId; + final CloudPayload payload; +} + +/// A page's lineups as cloud rows describe them, and the group each lineup +/// and spot was drawn from (lineup, origin or landing id to group id). +class CloudLineups { + const CloudLineups({required this.graph, this.groupOf = const {}}); + + final LineUpGraph graph; + final Map groupOf; +} + +/// The rows that store a page's lineups, and the group each lineup and spot +/// went into. +class CloudLineupRows { + const CloudLineupRows({required this.rows, required this.groupOf}); + + final List rows; + final Map groupOf; +} + +/// A page's lineups as its live [rows] describe them, rows in order. +/// +/// Ids are unique within a row and, as the server keeps them, across the +/// page's rows. Should two rows name one anyway, the first draws it, and a +/// lineup in a later row aimed at that spot is drawn to it too. +/// +/// Throws a [FormatException] naming the row when one cannot be read. +CloudLineups lineUpGraphFromCloudRows(Iterable rows) { + final origins = {}; + final landings = {}; + final links = {}; + final groupOf = {}; + for (final row in rows) { + final group = _readGroupRow(row); + for (final origin in group.origins) { + if (origins.containsKey(origin.id)) continue; + origins[origin.id] = origin; + groupOf[origin.id] = row.publicId; + } + for (final landing in group.landings) { + if (landings.containsKey(landing.id)) continue; + landings[landing.id] = landing; + groupOf[landing.id] = row.publicId; + } + for (final link in group.links) { + if (links.containsKey(link.id)) continue; + links[link.id] = link; + groupOf[link.id] = row.publicId; + } + } + return CloudLineups( + graph: LineUpGraph( + origins: origins.values.toList(), + landings: landings.values.toList(), + links: links.values.toList(), + ), + groupOf: groupOf, + ); +} + +/// The rows that store [graph], given the group each lineup and spot was in +/// before ([groupOf], from the rows the canvas was drawn from and earlier +/// calls). +/// +/// A lineup stays in the group it was first written to, so a group never +/// splits, and no row is ever deleted to tidy groups up: a group's row goes +/// only when its last lineup does. New lineups that share a spot form one +/// component, which joins the group one of its spots is already in (the +/// smallest such group's id, when spots of two groups meet in it), or else +/// is a new group. A new group takes its smallest lineup id that is not +/// already a group's id, here, in [groupOf] or in [takenGroupIds] (or a +/// fresh id, when every one of them is). +/// +/// A row holds its lineups and every spot they aim at. The app only ever +/// aims a new lineup at one existing spot, so a spot is in one row. A +/// lineup aimed at spots of two groups would put a spot in two rows; the +/// server refuses that write, and the work waits in attention. +/// +/// A lineup whose origin or landing is missing draws nothing on the canvas, +/// so it is not written; neither is a spot no lineup is aimed at. Rows come +/// in the order of their first lineup, their contents in [graph]'s order. +CloudLineupRows cloudLineupRows( + LineUpGraph graph, { + Map groupOf = const {}, + Set takenGroupIds = const {}, +}) { + final originIds = {for (final origin in graph.origins) origin.id}; + final landingIds = {for (final landing in graph.landings) landing.id}; + final links = [ + for (final link in graph.links) + if (originIds.contains(link.originId) && + landingIds.contains(link.landingId)) + link, + ]; + + // The group of each lineup already in one, and so of the spots they aim + // at, as far as [groupOf] does not already say. + final linkGroup = { + for (final link in links) + if (groupOf[link.id] case final group?) link.id: group, + }; + final spotGroup = {}; + for (final link in links) { + for (final spot in ['o:${link.originId}', 'l:${link.landingId}']) { + final group = groupOf[spot.substring(2)] ?? linkGroup[link.id]; + if (group != null) spotGroup.putIfAbsent(spot, () => group); + } + } + + // New lineups, joined through the spots they share: each kind of id in + // its own namespace, so an origin and a landing with one id stay apart. + final parent = {}; + String find(String node) { + final up = parent[node] ?? node; + if (up == node) return node; + return parent[node] = find(up); + } + + void union(String a, String b) { + final rootA = find(a); + final rootB = find(b); + if (rootA != rootB) parent[rootA] = rootB; + } + + final newLinks = [ + for (final link in links) + if (!linkGroup.containsKey(link.id)) link, + ]; + for (final link in newLinks) { + union('k:${link.id}', 'o:${link.originId}'); + union('k:${link.id}', 'l:${link.landingId}'); + } + final componentLinks = >{}; + final componentGroups = >{}; + for (final link in newLinks) { + final root = find('k:${link.id}'); + (componentLinks[root] ??= []).add(link); + for (final spot in ['o:${link.originId}', 'l:${link.landingId}']) { + if (spotGroup[spot] case final group?) { + (componentGroups[root] ??= {}).add(group); + } + } + } + + String smallest(Iterable ids) => + ids.reduce((a, b) => a.compareTo(b) <= 0 ? a : b); + final used = { + ...takenGroupIds, + ...groupOf.values, + ...linkGroup.values, + }; + for (final MapEntry(key: root, value: component) in componentLinks.entries) { + final String group; + if (componentGroups[root] case final groups?) { + group = smallest(groups); + } else { + final candidates = [for (final link in component) link.id]..sort(); + group = candidates.firstWhere( + (candidate) => !used.contains(candidate), + orElse: () => const Uuid().v4(), + ); + used.add(group); + } + for (final link in component) { + linkGroup[link.id] = group; + } + } + + final linksByGroup = >{}; + for (final link in links) { + (linksByGroup[linkGroup[link.id]!] ??= []).add(link); + } + final assigned = {}; + final rows = []; + for (final MapEntry(key: id, value: groupLinks) in linksByGroup.entries) { + final usedOrigins = {for (final link in groupLinks) link.originId}; + final usedLandings = {for (final link in groupLinks) link.landingId}; + final origins = [ + for (final origin in graph.origins) + if (usedOrigins.contains(origin.id)) origin, + ]; + final landings = [ + for (final landing in graph.landings) + if (usedLandings.contains(landing.id)) landing, + ]; + for (final link in groupLinks) { + assigned[link.id] = id; + } + // A spot carried by two rows stays with the group it was in. + for (final spot in [...usedOrigins, ...usedLandings]) { + assigned.putIfAbsent(spot, () => groupOf[spot] ?? id); + } + rows.add( + CloudLineupRow( + publicId: id, + payload: cloudLineupsPayload({ + 'id': id, + 'origins': [for (final origin in origins) origin.toJson()], + 'landings': [for (final landing in landings) landing.toJson()], + 'links': [for (final link in groupLinks) link.toJson()], + }), + ), + ); + } + return CloudLineupRows(rows: rows, groupOf: assigned); +} + +/// A page's lineups as its [lineups] on the server describe them. Deleted +/// rows draw nothing. +LineUpGraph lineUpGraphFromRemoteLineups(Iterable lineups) { + return lineUpGraphFromCloudRows([ + for (final lineup in lineups) + if (!lineup.deleted) CloudLineupRow.remote(lineup), + ]).graph; +} + +/// Whether [op] is a lineup change in a cloud format before one row per +/// lineup group: an origin, landing or link row keyed `:`. An +/// outbox written by such a build can still hold one; it is never sent. +bool isRetiredCloudLineupOp(StrategyOp op) { + if (op.entityType != StrategyOpEntityType.lineup) return false; + final payload = op.payload; + return (payload is Map && payload['kind'] != cloudLineupsPayloadKind) || + _retiredLineupKey.hasMatch(op.entityPublicId ?? ''); +} + +final _retiredLineupKey = RegExp(r'^lineup(Origin|Landing|Link):'); + +({ + List origins, + List landings, + List links, +}) _readGroupRow(CloudLineupRow row) { + try { + final kind = row.payload['kind']; + if (kind != cloudLineupsPayloadKind) { + throw FormatException('unknown lineup kind $kind'); + } + final data = cloudPayloadData(row.payload); + if (data['id'] != row.publicId) { + throw FormatException('it holds group ${data['id']}'); + } + final group = ( + origins: [ + for (final json in _objects(data['origins'], 'origins')) + LineUpOrigin.fromJson(json), + ], + landings: [ + for (final json in _objects(data['landings'], 'landings')) + LineUpLanding.fromJson(json), + ], + links: [ + for (final json in _objects(data['links'], 'links')) + LineUpLink.fromJson(json), + ], + ); + final originIds = {for (final origin in group.origins) origin.id}; + final landingIds = {for (final landing in group.landings) landing.id}; + for (final link in group.links) { + if (!originIds.contains(link.originId) || + !landingIds.contains(link.landingId)) { + throw FormatException('lineup ${link.id} is aimed outside the row'); + } + } + return group; + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FormatException('Cloud lineup group ${row.publicId} could not be read: ' + '$error'), + stackTrace, + ); + } +} + +List> _objects(Object? value, String name) { + if (value is! List) throw FormatException('it has no $name'); + return [ + for (final entry in value) + if (entry is Map) + Map.from(entry) + else + throw FormatException('its $name hold something other than objects'), + ]; +} diff --git a/lib/collab/cloud_media_models.dart b/lib/collab/cloud_media_models.dart new file mode 100644 index 00000000..0e1713ab --- /dev/null +++ b/lib/collab/cloud_media_models.dart @@ -0,0 +1,193 @@ +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; + +enum CloudMediaJobState { pendingUpload, pendingAttach, failed } + +String normalizeImageExtension(String extension) { + if (extension.isEmpty) { + return extension; + } + return extension.startsWith('.') + ? extension.toLowerCase() + : '.${extension.toLowerCase()}'; +} + +String mimeTypeForImageExtension(String extension) { + switch (normalizeImageExtension(extension)) { + case '.png': + return 'image/png'; + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.bmp': + return 'image/bmp'; + default: + return 'application/octet-stream'; + } +} + +class CloudMediaUploadJob { + CloudMediaUploadJob({ + required this.jobId, + required this.accountId, + required this.strategyPublicId, + required this.assetPublicId, + required this.fileExtension, + required this.mimeType, + required this.state, + required this.attempts, + required this.updatedAt, + this.referenceDurable = true, + this.width, + this.height, + this.byteSize, + this.provider, + this.uploadId, + this.objectKey, + this.storageId, + this.etag, + this.uploadUrlExpiresAt, + this.lastError, + }); + + final String jobId; + final String accountId; + final String strategyPublicId; + final String assetPublicId; + final String fileExtension; + final String mimeType; + final int? width; + final int? height; + final int? byteSize; + final String? provider; + final String? uploadId; + final String? objectKey; + final String? storageId; + final String? etag; + final DateTime? uploadUrlExpiresAt; + final CloudMediaJobState state; + final bool referenceDurable; + final int attempts; + final String? lastError; + final DateTime updatedAt; + + bool get isFailed => state == CloudMediaJobState.failed; + + bool get hasUploadedRemoteObject { + if (provider == 'r2') { + return uploadId != null && objectKey != null; + } + return storageId != null; + } + + CloudMediaUploadJob copyWith({ + String? jobId, + String? accountId, + String? strategyPublicId, + String? assetPublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + Object? byteSize = _noChange, + Object? provider = _noChange, + Object? uploadId = _noChange, + Object? objectKey = _noChange, + Object? storageId = _noChange, + Object? etag = _noChange, + Object? uploadUrlExpiresAt = _noChange, + CloudMediaJobState? state, + bool? referenceDurable, + int? attempts, + Object? lastError = _noChange, + DateTime? updatedAt, + }) { + return CloudMediaUploadJob( + jobId: jobId ?? this.jobId, + accountId: accountId ?? this.accountId, + strategyPublicId: strategyPublicId ?? this.strategyPublicId, + assetPublicId: assetPublicId ?? this.assetPublicId, + fileExtension: fileExtension ?? this.fileExtension, + mimeType: mimeType ?? this.mimeType, + width: width ?? this.width, + height: height ?? this.height, + byteSize: + identical(byteSize, _noChange) ? this.byteSize : byteSize as int?, + provider: + identical(provider, _noChange) ? this.provider : provider as String?, + uploadId: + identical(uploadId, _noChange) ? this.uploadId : uploadId as String?, + objectKey: identical(objectKey, _noChange) + ? this.objectKey + : objectKey as String?, + storageId: identical(storageId, _noChange) + ? this.storageId + : storageId as String?, + etag: identical(etag, _noChange) ? this.etag : etag as String?, + uploadUrlExpiresAt: identical(uploadUrlExpiresAt, _noChange) + ? this.uploadUrlExpiresAt + : uploadUrlExpiresAt as DateTime?, + state: state ?? this.state, + referenceDurable: referenceDurable ?? this.referenceDurable, + attempts: attempts ?? this.attempts, + lastError: identical(lastError, _noChange) + ? this.lastError + : lastError as String?, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +class CloudImageUploadIntent { + const CloudImageUploadIntent({ + required this.provider, + required this.uploadId, + required this.objectKey, + required this.uploadUrl, + required this.requiredHeaders, + required this.expiresAt, + required this.maxBytes, + }); + + final String provider; + final String uploadId; + final String objectKey; + final String uploadUrl; + final Map requiredHeaders; + final DateTime expiresAt; + final int maxBytes; +} + +Map cloudImagePayloadFromPlacedImage(PlacedImage image) { + return Map.from(image.toJson())..remove('link'); +} + +Set collectStrategyImageAssetIds(StrategyDataLike strategy) { + final assetIds = {}; + for (final page in strategy.pages) { + for (final image in page.imageData) { + assetIds.add(image.id); + } + for (final link in page.lineUpLinks) { + for (final image in link.images) { + assetIds.add(image.id); + } + } + } + return assetIds; +} + +abstract class StrategyDataLike { + Iterable get pages; +} + +abstract class StrategyPageLike { + Iterable get imageData; + Iterable get lineUpLinks; +} + +const _noChange = Object(); diff --git a/lib/collab/cloud_payload_upgrade.dart b/lib/collab/cloud_payload_upgrade.dart new file mode 100644 index 00000000..cedbd421 --- /dev/null +++ b/lib/collab/cloud_payload_upgrade.dart @@ -0,0 +1,129 @@ +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/migrations/paranoia_range_migration.dart'; + +/// The payload version of an ability row holding a Paranoia drawn at its +/// in-game size (data version 104). Every other row stays at +/// [currentCloudPayloadVersion]: nothing else changed shape. +/// +/// A Paranoia ability row below this version was written by a client that +/// drew the old 25 m Paranoia. It is corrected as it is read, never +/// rewritten on the server, so every client reads it the same way until +/// someone edits it. +/// +/// Lineup rows are never corrected. The lineup group row (protocol 5) came +/// after data version 104, so every lineup row holds its Paranoia at the +/// in-game size already; correcting one would move it twice. +const paranoiaCloudPayloadVersion = 2; + +/// Ability [payload] with its Paranoia, if any, at the in-game size on +/// [map]. Payloads without a Paranoia, those already corrected, and those of +/// any other kind come back as they are. +CloudPayload upgradeCloudPayload(CloudPayload payload, MapValue map) { + final version = payload['payloadVersion']; + if (version is num && version >= paranoiaCloudPayloadVersion) { + return payload; + } + final data = payload['data']; + if (data is! Map) return payload; + if (payload['kind'] != 'ability') return payload; + final upgraded = _upgradeAbilityJson(data, map); + if (identical(upgraded, data)) return payload; + return { + ...payload, + 'payloadVersion': paranoiaCloudPayloadVersion, + 'data': upgraded, + }; +} + +/// The version a client writes element [data] of [kind] at. +int cloudPayloadVersionFor(String kind, Map data) { + return kind == 'ability' && _isParanoiaJson(data) + ? paranoiaCloudPayloadVersion + : currentCloudPayloadVersion; +} + +/// [page] of a strategy on the map named [mapData], as this build draws it. +RemotePageSnapshot upgradeRemotePageSnapshot( + RemotePageSnapshot page, + String mapData, +) { + final map = _mapValueOrNull(mapData); + if (map == null) return page; + return RemotePageSnapshot( + page: page.page, + content: page.content, + elements: [for (final e in page.elements) _upgradeElement(e, map)], + lineups: page.lineups, + assetsById: page.assetsById, + ); +} + +/// [snapshot] as this build draws it. +RemoteFullStrategySnapshot upgradeRemoteFullSnapshot( + RemoteFullStrategySnapshot snapshot, +) { + final map = _mapValueOrNull(snapshot.header.mapData); + if (map == null) return snapshot; + return RemoteFullStrategySnapshot( + header: snapshot.header, + pages: snapshot.pages, + elementsByPage: { + for (final MapEntry(:key, :value) in snapshot.elementsByPage.entries) + key: [for (final e in value) _upgradeElement(e, map)], + }, + lineupsByPage: snapshot.lineupsByPage, + assetsById: snapshot.assetsById, + ); +} + +RemoteElement _upgradeElement(RemoteElement element, MapValue map) { + final payload = upgradeCloudPayload(element.payload, map); + if (identical(payload, element.payload)) return element; + return RemoteElement( + publicId: element.publicId, + strategyPublicId: element.strategyPublicId, + pagePublicId: element.pagePublicId, + elementType: element.elementType, + payload: payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + ); +} + +/// A map this build does not know cannot be drawn, so its rows stay as the +/// server holds them. +MapValue? _mapValueOrNull(String wireName) { + for (final entry in Maps.mapNames.entries) { + if (entry.value == wireName) return entry.key; + } + return null; +} + +bool _isParanoiaJson(Map ability) { + final info = ability['data']; + return info is Map && + info['type'] == AgentType.omen.name && + info['index'] == 1; +} + +/// [ability] with only its position moved, or [ability] itself when it is +/// not a Paranoia. A Paranoia this build cannot read is left as it is; +/// hydration skips it the same way. +Map _upgradeAbilityJson( + Map ability, + MapValue map, +) { + if (!_isParanoiaJson(ability)) return ability; + final PlacedAbility placed; + try { + placed = PlacedAbility.fromJson(ability); + } on Object { + return ability; + } + final moved = ParanoiaRangeMigration.migrateAbility(placed, map); + return {...ability, 'position': moved.toJson()['position']}; +} diff --git a/lib/collab/cloud_sync_error_message.dart b/lib/collab/cloud_sync_error_message.dart new file mode 100644 index 00000000..b2a2b3c2 --- /dev/null +++ b/lib/collab/cloud_sync_error_message.dart @@ -0,0 +1,180 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/generated/convex_error_codes.dart'; +import 'package:icarus/collab/src/convex_client_types.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +final _urlQuery = RegExp(r'''(https?://[^\s?#"'<>]+)\?[^\s"'<>]*'''); +final _secretKeyValue = RegExp( + r'''((?:access_token|refresh_token|provider_token|provider_refresh_token|code_verifier|token|X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token)["']?\s*[=:]\s*["']?)[^&#,;\s}\]"']+''', + caseSensitive: false, +); + +/// [error] as text safe to log for a sync or upload failure. URLs lose their +/// query (a presigned upload URL carries its signature and credential there), +/// and anything shaped like a token assignment is replaced with ``. +String redactSyncDiagnosticText(Object? error) { + return '$error' + .replaceAllMapped(_urlQuery, (match) => '${match.group(1)}?') + .replaceAllMapped( + _secretKeyValue, (match) => '${match.group(1)}'); +} + +/// Follows a specific reason in the queue's error when other saved work +/// needs attention too, for whatever reason. +const otherWorkNeedsAttentionNote = 'Other saved work needs attention too.'; + +/// Whether [error] is the specific reason saved work needs attention, one +/// that [friendlyCloudSyncError] explains, rather than an edit that lost a +/// race to another. The sync button shows it in place of the generic +/// conflict text. +bool isSpecificAttentionReason(String error) { + final lower = error.toLowerCase(); + return lower.contains('too large for cloud sync') || + lower.contains(lineupPageMismatchMessage.toLowerCase()) || + lower.contains(lineupOverlapMessage.toLowerCase()) || + lower.contains(retiredLineupOpMessage.toLowerCase()) || + lower.contains(teammateDeletedMessage.toLowerCase()) || + lower.contains(pageDeletedMessage.toLowerCase()); +} + +/// A cloud change whose write to the durable outbox failed, or could not be +/// confirmed: it may not be on this device, and it was not sent. +const unverifiedCloudWorkMessage = + 'Icarus could not verify that this change was saved on this device. ' + 'Nothing was sent. Keep this strategy open and retry.'; + +/// The server's code for a build whose cloud protocol it no longer accepts. +const clientUpgradeRequiredCode = 'CLIENT_UPGRADE_REQUIRED'; + +/// The queue's error while the server refuses this build. +const clientUpgradeRequiredQueueError = + 'Cloud sync is held until Icarus is updated ($clientUpgradeRequiredCode).'; + +/// Whether [error] is the server refusing this build. Nothing the build +/// sends or reads will be accepted until it is reloaded (web) or updated +/// (desktop). It is never the user's fault, nor the fault of the work it +/// refused. Text is matched by [isClientUpgradeRequiredReason]. +bool isClientUpgradeRequiredError(Object? error) => switch (error) { + ConvexFunctionException(:final rawCode) || + ConvexClientFunctionError(:final rawCode) || + ConvexTransportError(:final rawCode) => + rawCode == clientUpgradeRequiredCode, + String() => isClientUpgradeRequiredReason(error), + _ => false, + }; + +/// Whether [reason], an error kept as text, is the server refusing a build: +/// exactly the text this build keeps, or the text a refused send left in the +/// outbox ('$error' of the generated client's exception, which the protocol 3 +/// web build wrote too). Text that merely mentions the code is not. +bool isClientUpgradeRequiredReason(String? reason) => + reason == clientUpgradeRequiredQueueError || reason == _keptRefusal; + +const _keptRefusal = + 'ConvexFunctionException($clientUpgradeRequiredCode, Client upgrade required)'; + +/// What the user is told when the server needs a newer Icarus, with the way +/// out their platform has. Desktop says what it can only once it knows +/// whether an update is there to install. +String clientUpgradeRequiredMessage({ + bool isWeb = kIsWeb, + ClientUpdateAvailability update = ClientUpdateAvailability.available, +}) { + if (isWeb) return 'Icarus was updated. Reload to keep syncing.'; + return switch (update) { + ClientUpdateAvailability.available => + 'Icarus was updated. Install the update to keep syncing.', + ClientUpdateAvailability.checking => + 'Icarus was updated. Looking for the update this app needs to keep ' + 'syncing.', + ClientUpdateAvailability.unavailable => + "Icarus was updated, but the update isn't available for this app yet. " + 'Your saved work stays on this device and syncs once the update is ' + 'installed.', + }; +} + +/// Whether the update a refused desktop build needs can be installed yet. +enum ClientUpdateAvailability { available, checking, unavailable } + +String friendlyCloudSyncError(String raw) { + if (isClientUpgradeRequiredReason(raw)) { + return clientUpgradeRequiredMessage(); + } + final lower = raw.toLowerCase(); + if (lower.contains('strategy was deleted')) { + return 'This strategy was deleted, so its unsent changes cannot be ' + 'saved. Discard them from the library.'; + } + if (lower.contains('unreadable saved work')) { + return 'A saved cloud change could not be read. It remains on this ' + 'device; keep this strategy open and recover the outbox before ' + 'continuing.'; + } + if (lower.contains('could not be verified in the durable outbox') || + lower.contains('could not be saved to the durable outbox')) { + return unverifiedCloudWorkMessage; + } + if (lower.contains('forbidden')) { + return 'This account does not have permission to save these changes. ' + 'They remain on this device. Ask the owner for edit access, then ' + 'retry.'; + } + if (lower.contains('retry paused')) { + return 'A saved cloud change is paused after repeated failures. Retry ' + 'when the connection and account are healthy.'; + } + if (lower.contains('too large for cloud sync')) { + return 'A saved change is too large for cloud sync. It remains saved on ' + 'this device. Reduce it, then choose Keep mine to retry, or Use ' + 'cloud to drop it.'; + } + if (lower.contains(lineupOverlapMessage.toLowerCase())) { + return 'This lineup shares a spot with lineups saved separately, so it ' + 'was not saved to the cloud. It remains on this device. Keep mine ' + 'sends it again, and is refused while the spot is still shared; Use ' + 'cloud removes it here.'; + } + if (lower.contains(lineupPageMismatchMessage.toLowerCase())) { + return 'This lineup clashes with one on another page, so it was not ' + 'saved to the cloud. It remains on this device; Use cloud removes ' + 'it here.'; + } + if (lower.contains(teammateDeletedCannotRestoreMessage.toLowerCase())) { + return 'A teammate deleted what this change edits, and this change ' + 'cannot bring it back. Use cloud to let it go.'; + } + if (lower.contains(teammateDeletedMessage.toLowerCase())) { + return 'A teammate deleted what you changed. Keep mine brings it back ' + 'as you have it; Use cloud lets it go.'; + } + if (lower.contains(retiredLineupOpMessage.toLowerCase())) { + return 'A lineup change saved by an older version of Icarus cannot be ' + 'sent to the cloud, so it is not in this strategy. Redo it, then ' + 'choose Use cloud to clear this.'; + } + if (lower.contains(pageDeletedMessage.toLowerCase())) { + return 'The page these changes were on was deleted, so they were not ' + 'saved. They remain on this device; Use cloud drops them.'; + } + if (lower.contains('needs attention')) { + return 'Another edit reached the cloud first. Your version remains ' + 'saved on this device.'; + } + if (lower.contains('cannot be retried automatically')) { + return 'The server cannot match this retained edit to a current cloud ' + 'revision. It remains saved on this device.'; + } + if (lower.contains('auth')) { + return 'Your cloud session needs to be refreshed — retry, or sign in ' + 'again from the library.'; + } + if (lower.contains('offline') || lower.contains('connection')) { + return 'The cloud could not be reached.'; + } + if (lower.contains('setup is not ready')) { + return 'Cloud sync is still starting up.'; + } + return "Some changes haven't reached the cloud yet."; +} diff --git a/lib/collab/collab_models.dart b/lib/collab/collab_models.dart new file mode 100644 index 00000000..2d95dd39 --- /dev/null +++ b/lib/collab/collab_models.dart @@ -0,0 +1,1543 @@ +import 'dart:convert'; + +import 'package:icarus/collab/cloud_payload_upgrade.dart'; +import 'package:icarus/const/sort_index_order.dart'; + +const currentCloudProtocolVersion = 5; +const currentCloudPayloadVersion = 1; +const maxCloudOperationBytes = 900 * 1024; +const maxCloudBatchBytes = 15 * 1024 * 1024; +const maxCloudArrayEntries = 8000; +const cloudOperationTooLargeMessage = + 'This saved change is too large for cloud sync. It remains saved on this ' + 'device.'; + +/// Marks outbox records whose strategy the server no longer has (it was +/// deleted, here or on another device). They are never retried; the user +/// discards them from the library. +const cloudStrategyDeletedMessage = + 'This strategy was deleted, so its unsent changes cannot be saved.'; + +/// The server's message when it refuses a lineup whose row already lives on +/// another page (LINEUP_PAGE_MISMATCH). +const lineupPageMismatchMessage = + 'This lineup belongs to another page and cannot be moved'; + +/// The server's message when it refuses a lineup group row holding a +/// lineup or spot another group of the page holds (see assertLineupGroupAlone +/// in convex/lib/lineupItems.ts). Sending it again cannot help. +const lineupOverlapMessage = + 'A lineup or spot in this group is already in another group'; + +/// Marks an outbox record holding a lineup change in the cloud format before +/// one row per lineup (an origin, landing or link row). No server takes it +/// any more, so it is never sent; it waits in attention until the user +/// discards it. +const retiredLineupOpMessage = + 'This lineup change was saved in an older cloud format and cannot be ' + 'sent'; + +/// Why saved work waits when the server refused it because a teammate +/// deleted what it edits (rejection reason `deleted`). Keep mine brings the +/// item back as the user has it. +const teammateDeletedMessage = 'A teammate deleted what this change edits'; + +/// Why such work stays after Keep mine: a reorder, say, holds nothing to +/// bring the item back with. +const teammateDeletedCannotRestoreMessage = + 'A teammate deleted what this change edits, and this change cannot ' + 'bring it back'; + +/// The server's message when it refuses a change to a page in its trash +/// (PAGE_DELETED). Such a change lands if the page is restored and it is +/// sent again. +const pageDeletedMessage = 'This page was deleted'; + +/// How long the server keeps a deleted page restorable (the server's +/// PAGE_TRASH_RETENTION_MS), for copy that promises it. +const pageTrashRetentionDays = 30; + +typedef CloudPayload = Map; + +int serializedConvexValueUtf8Bytes(Object? value) => + utf8.encode(jsonEncode(value)).length; + +CloudPayload cloudElementPayload({ + required String kind, + required Map data, +}) { + final normalized = _normalizeCloudPayloadData(data); + return { + 'kind': kind, + 'payloadVersion': cloudPayloadVersionFor(kind, normalized), + 'data': normalized, + }; +} + +/// The payload kind of a lineup group row (see cloud_lineup_rows.dart). +const cloudLineupsPayloadKind = 'lineups'; + +/// One lineup group row's payload. Lineup rows are never corrected on read +/// (see cloud_payload_upgrade.dart), so they all stay at the current +/// version. +CloudPayload cloudLineupsPayload(Map data) { + return { + 'kind': cloudLineupsPayloadKind, + 'payloadVersion': currentCloudPayloadVersion, + 'data': _normalizeCloudPayloadData(data), + }; +} + +Map _normalizeCloudPayloadData(Map data) { + final normalized = jsonDecode(jsonEncode(data)); + if (normalized is Map) { + return normalized; + } + if (normalized is Map) { + return Map.from(normalized); + } + return {}; +} + +Map cloudPayloadData(Object? payload) { + if (payload is Map) { + final data = payload['data']; + if (data is Map) { + return _normalizeCloudPayloadData(data); + } + if (data is Map) { + return _normalizeCloudPayloadData(Map.from(data)); + } + return _normalizeCloudPayloadData(payload); + } + if (payload is Map) { + return cloudPayloadData(Map.from(payload)); + } + return {}; +} + +CloudPayload? cloudObjectPayloadOrNull(Object? payload) { + if (payload == null) { + return null; + } + Object? decoded = payload; + if (payload is String) { + try { + decoded = jsonDecode(payload); + } catch (_) { + return null; + } + } + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + return null; +} + +CloudPayload cloudObjectPayload(Object? payload) { + return cloudObjectPayloadOrNull(payload) ?? {}; +} + +enum StrategyOpType { + strategyPatch('strategy.patch'), + pageAdd('page.add'), + pagePatch('page.patch'), + pageDelete('page.delete'), + pageReorder('page.reorder'), + pageContentPatch('pageContent.patch'), + elementAdd('element.add'), + elementPatch('element.patch'), + elementDelete('element.delete'), + elementReorder('element.reorder'), + lineupAdd('lineup.add'), + lineupPatch('lineup.patch'), + lineupDelete('lineup.delete'), + lineupReorder('lineup.reorder'); + + const StrategyOpType(this.wireName); + + final String wireName; + + static StrategyOpType fromWireName(String value) => + values.firstWhere((type) => type.wireName == value); +} + +enum StrategyOpKind { add, patch, delete, reorder } + +enum StrategyOpEntityType { strategy, page, pageContent, element, lineup } + +sealed class StrategyOp { + const StrategyOp(); + + String get opId; + StrategyOpType get type; + StrategyOpKind get kind => switch (type) { + StrategyOpType.strategyPatch || + StrategyOpType.pagePatch || + StrategyOpType.pageContentPatch || + StrategyOpType.elementPatch || + StrategyOpType.lineupPatch => + StrategyOpKind.patch, + StrategyOpType.pageAdd || + StrategyOpType.elementAdd || + StrategyOpType.lineupAdd => + StrategyOpKind.add, + StrategyOpType.pageDelete || + StrategyOpType.elementDelete || + StrategyOpType.lineupDelete => + StrategyOpKind.delete, + StrategyOpType.pageReorder || + StrategyOpType.elementReorder || + StrategyOpType.lineupReorder => + StrategyOpKind.reorder, + }; + StrategyOpEntityType get entityType => switch (type) { + StrategyOpType.strategyPatch => StrategyOpEntityType.strategy, + StrategyOpType.pageAdd || + StrategyOpType.pagePatch || + StrategyOpType.pageDelete || + StrategyOpType.pageReorder => + StrategyOpEntityType.page, + StrategyOpType.pageContentPatch => StrategyOpEntityType.pageContent, + StrategyOpType.elementAdd || + StrategyOpType.elementPatch || + StrategyOpType.elementDelete || + StrategyOpType.elementReorder => + StrategyOpEntityType.element, + StrategyOpType.lineupAdd || + StrategyOpType.lineupPatch || + StrategyOpType.lineupDelete || + StrategyOpType.lineupReorder => + StrategyOpEntityType.lineup, + }; + + String? get entityPublicId => switch (this) { + StrategyPatchOp() => null, + PageAddOp(:final pagePublicId) || + PagePatchOp(:final pagePublicId) || + PageDeleteOp(:final pagePublicId) || + PageReorderOp(:final pagePublicId) || + PageContentPatchOp(:final pagePublicId) => + pagePublicId, + ElementAddOp(:final elementPublicId) || + ElementPatchOp(:final elementPublicId) || + ElementDeleteOp(:final elementPublicId) || + ElementReorderOp(:final elementPublicId) => + elementPublicId, + LineupAddOp(:final lineupPublicId) || + LineupPatchOp(:final lineupPublicId) || + LineupDeleteOp(:final lineupPublicId) || + LineupReorderOp(:final lineupPublicId) => + lineupPublicId, + }; + + String? get pagePublicId => switch (this) { + PageAddOp(:final pagePublicId) || + PagePatchOp(:final pagePublicId) || + PageDeleteOp(:final pagePublicId) || + PageReorderOp(:final pagePublicId) || + PageContentPatchOp(:final pagePublicId) || + ElementAddOp(:final pagePublicId) || + LineupAddOp(:final pagePublicId) => + pagePublicId, + ElementPatchOp(:final pagePublicId) || + LineupPatchOp(:final pagePublicId) => + pagePublicId, + ElementDeleteOp(:final pagePublicId) || + ElementReorderOp(:final pagePublicId) || + LineupDeleteOp(:final pagePublicId) || + LineupReorderOp(:final pagePublicId) => + pagePublicId, + StrategyPatchOp() => null, + }; + + Object? get payload => switch (this) { + StrategyPatchOp(:final payload) || + PageAddOp(:final payload) || + PagePatchOp(:final payload) || + ElementAddOp(:final payload) || + LineupAddOp(:final payload) => + payload, + ElementPatchOp(:final payload) || + LineupPatchOp(:final payload) => + payload, + PageContentPatchOp(:final settings) => {'settings': settings}, + PageDeleteOp() || + PageReorderOp() || + ElementDeleteOp() || + ElementReorderOp() || + LineupDeleteOp() || + LineupReorderOp() => + null, + }; + + int? get sortIndex => switch (this) { + PageAddOp(:final sortIndex) || + PageReorderOp(:final sortIndex) || + ElementAddOp(:final sortIndex) || + ElementReorderOp(:final sortIndex) || + LineupAddOp(:final sortIndex) || + LineupReorderOp(:final sortIndex) => + sortIndex, + ElementPatchOp(:final sortIndex) || + LineupPatchOp(:final sortIndex) => + sortIndex, + StrategyPatchOp() || + PagePatchOp() || + PageDeleteOp() || + PageContentPatchOp() || + ElementDeleteOp() || + LineupDeleteOp() => + null, + }; + + int? get expectedRevision => switch (this) { + StrategyPatchOp(:final expectedStrategyRevision) || + PageAddOp(:final expectedStrategyRevision) || + PageDeleteOp(:final expectedStrategyRevision) || + PageReorderOp(:final expectedStrategyRevision) => + expectedStrategyRevision, + PagePatchOp(:final expectedPageRevision) => expectedPageRevision, + PageContentPatchOp(:final expectedPageContentRevision) => + expectedPageContentRevision, + ElementAddOp(:final expectedElementRevision) => expectedElementRevision, + ElementPatchOp(:final expectedElementRevision) || + ElementDeleteOp(:final expectedElementRevision) || + ElementReorderOp(:final expectedElementRevision) => + expectedElementRevision, + LineupAddOp(:final expectedLineupRevision) => expectedLineupRevision, + LineupPatchOp(:final expectedLineupRevision) || + LineupDeleteOp(:final expectedLineupRevision) || + LineupReorderOp(:final expectedLineupRevision) => + expectedLineupRevision, + }; + + Map toConvexJson() => switch (this) { + StrategyPatchOp() => { + 'opId': opId, + 'type': type.wireName, + 'payload': payload, + 'expectedStrategyRevision': expectedRevision, + }, + PageAddOp() => { + 'opId': opId, + 'type': type.wireName, + 'pagePublicId': pagePublicId, + 'payload': payload, + 'sortIndex': sortIndex, + 'expectedStrategyRevision': expectedRevision, + }, + PagePatchOp() => { + 'opId': opId, + 'type': type.wireName, + 'pagePublicId': pagePublicId, + 'payload': payload, + 'expectedPageRevision': expectedRevision, + }, + PageDeleteOp() => { + 'opId': opId, + 'type': type.wireName, + 'pagePublicId': pagePublicId, + 'expectedStrategyRevision': expectedRevision, + }, + PageReorderOp() => { + 'opId': opId, + 'type': type.wireName, + 'pagePublicId': pagePublicId, + 'sortIndex': sortIndex, + 'expectedStrategyRevision': expectedRevision, + }, + PageContentPatchOp(:final settings) => { + 'opId': opId, + 'type': type.wireName, + 'pagePublicId': pagePublicId, + 'settings': settings, + 'expectedPageContentRevision': expectedRevision, + }, + ElementAddOp() => { + 'opId': opId, + 'type': type.wireName, + 'elementPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'payload': payload, + 'sortIndex': sortIndex, + if (expectedRevision != null) + 'expectedElementRevision': expectedRevision, + }, + ElementPatchOp() => { + 'opId': opId, + 'type': type.wireName, + 'elementPublicId': entityPublicId, + if (pagePublicId != null) 'pagePublicId': pagePublicId, + if (payload != null) 'payload': payload, + if (sortIndex != null) 'sortIndex': sortIndex, + 'expectedElementRevision': expectedRevision, + }, + ElementDeleteOp() => { + 'opId': opId, + 'type': type.wireName, + 'elementPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'expectedElementRevision': expectedRevision, + }, + ElementReorderOp() => { + 'opId': opId, + 'type': type.wireName, + 'elementPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'sortIndex': sortIndex, + 'expectedElementRevision': expectedRevision, + }, + LineupAddOp() => { + 'opId': opId, + 'type': type.wireName, + 'lineupPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'payload': payload, + 'sortIndex': sortIndex, + if (expectedRevision != null) + 'expectedLineupRevision': expectedRevision, + }, + LineupPatchOp() => { + 'opId': opId, + 'type': type.wireName, + 'lineupPublicId': entityPublicId, + if (pagePublicId != null) 'pagePublicId': pagePublicId, + if (payload != null) 'payload': payload, + if (sortIndex != null) 'sortIndex': sortIndex, + 'expectedLineupRevision': expectedRevision, + }, + LineupDeleteOp() => { + 'opId': opId, + 'type': type.wireName, + 'lineupPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'expectedLineupRevision': expectedRevision, + }, + LineupReorderOp() => { + 'opId': opId, + 'type': type.wireName, + 'lineupPublicId': entityPublicId, + 'pagePublicId': pagePublicId, + 'sortIndex': sortIndex, + 'expectedLineupRevision': expectedRevision, + }, + }; + + factory StrategyOp.fromJson(Map json) { + final opId = json['opId'] as String; + return switch (StrategyOpType.fromWireName(json['type'] as String)) { + StrategyOpType.strategyPatch => StrategyPatchOp( + opId: opId, + payload: _requiredMap(json['payload'], 'payload'), + expectedStrategyRevision: + _requiredInt(json['expectedStrategyRevision']), + ), + StrategyOpType.pageAdd => PageAddOp( + opId: opId, + pagePublicId: json['pagePublicId'] as String, + payload: _requiredMap(json['payload'], 'payload'), + sortIndex: _requiredInt(json['sortIndex']), + expectedStrategyRevision: + _requiredInt(json['expectedStrategyRevision']), + ), + StrategyOpType.pagePatch => PagePatchOp( + opId: opId, + pagePublicId: json['pagePublicId'] as String, + payload: _requiredMap(json['payload'], 'payload'), + expectedPageRevision: _requiredInt(json['expectedPageRevision']), + ), + StrategyOpType.pageDelete => PageDeleteOp( + opId: opId, + pagePublicId: json['pagePublicId'] as String, + expectedStrategyRevision: + _requiredInt(json['expectedStrategyRevision']), + ), + StrategyOpType.pageReorder => PageReorderOp( + opId: opId, + pagePublicId: json['pagePublicId'] as String, + sortIndex: _requiredInt(json['sortIndex']), + expectedStrategyRevision: + _requiredInt(json['expectedStrategyRevision']), + ), + StrategyOpType.pageContentPatch => PageContentPatchOp( + opId: opId, + pagePublicId: json['pagePublicId'] as String, + settings: _requiredMap(json['settings'], 'settings'), + expectedPageContentRevision: + _requiredInt(json['expectedPageContentRevision']), + ), + StrategyOpType.elementAdd => ElementAddOp( + opId: opId, + elementPublicId: json['elementPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + payload: _requiredMap(json['payload'], 'payload'), + sortIndex: _requiredInt(json['sortIndex']), + expectedElementRevision: + (json['expectedElementRevision'] as num?)?.toInt(), + ), + StrategyOpType.elementPatch => ElementPatchOp( + opId: opId, + elementPublicId: json['elementPublicId'] as String, + pagePublicId: json['pagePublicId'] as String?, + payload: _optionalMap(json['payload']), + sortIndex: (json['sortIndex'] as num?)?.toInt(), + expectedElementRevision: + _requiredInt(json['expectedElementRevision']), + ), + StrategyOpType.elementDelete => ElementDeleteOp( + opId: opId, + elementPublicId: json['elementPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + expectedElementRevision: + _requiredInt(json['expectedElementRevision']), + ), + StrategyOpType.elementReorder => ElementReorderOp( + opId: opId, + elementPublicId: json['elementPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + sortIndex: _requiredInt(json['sortIndex']), + expectedElementRevision: + _requiredInt(json['expectedElementRevision']), + ), + StrategyOpType.lineupAdd => LineupAddOp( + opId: opId, + lineupPublicId: json['lineupPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + payload: _requiredMap(json['payload'], 'payload'), + sortIndex: _requiredInt(json['sortIndex']), + expectedLineupRevision: + (json['expectedLineupRevision'] as num?)?.toInt(), + ), + StrategyOpType.lineupPatch => LineupPatchOp( + opId: opId, + lineupPublicId: json['lineupPublicId'] as String, + pagePublicId: json['pagePublicId'] as String?, + payload: _optionalMap(json['payload']), + sortIndex: (json['sortIndex'] as num?)?.toInt(), + expectedLineupRevision: _requiredInt(json['expectedLineupRevision']), + ), + StrategyOpType.lineupDelete => LineupDeleteOp( + opId: opId, + lineupPublicId: json['lineupPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + expectedLineupRevision: _requiredInt(json['expectedLineupRevision']), + ), + StrategyOpType.lineupReorder => LineupReorderOp( + opId: opId, + lineupPublicId: json['lineupPublicId'] as String, + pagePublicId: json['pagePublicId'] as String, + sortIndex: _requiredInt(json['sortIndex']), + expectedLineupRevision: _requiredInt(json['expectedLineupRevision']), + ), + }; + } + + StrategyOp withOpId(String value) => switch (this) { + StrategyPatchOp(:final payload, :final expectedStrategyRevision) => + StrategyPatchOp( + opId: value, + payload: payload, + expectedStrategyRevision: expectedStrategyRevision, + ), + PageAddOp( + :final pagePublicId, + :final payload, + :final sortIndex, + :final expectedStrategyRevision, + ) => + PageAddOp( + opId: value, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedStrategyRevision: expectedStrategyRevision, + ), + PagePatchOp( + :final pagePublicId, + :final payload, + :final expectedPageRevision, + ) => + PagePatchOp( + opId: value, + pagePublicId: pagePublicId, + payload: payload, + expectedPageRevision: expectedPageRevision, + ), + PageDeleteOp(:final pagePublicId, :final expectedStrategyRevision) => + PageDeleteOp( + opId: value, + pagePublicId: pagePublicId, + expectedStrategyRevision: expectedStrategyRevision, + ), + PageReorderOp( + :final pagePublicId, + :final sortIndex, + :final expectedStrategyRevision, + ) => + PageReorderOp( + opId: value, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedStrategyRevision: expectedStrategyRevision, + ), + PageContentPatchOp( + :final pagePublicId, + :final settings, + :final expectedPageContentRevision, + ) => + PageContentPatchOp( + opId: value, + pagePublicId: pagePublicId, + settings: settings, + expectedPageContentRevision: expectedPageContentRevision, + ), + ElementAddOp( + :final elementPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + :final expectedElementRevision, + ) => + ElementAddOp( + opId: value, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: expectedElementRevision, + ), + ElementPatchOp( + :final elementPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + :final expectedElementRevision, + ) => + ElementPatchOp( + opId: value, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: expectedElementRevision, + ), + ElementDeleteOp( + :final elementPublicId, + :final pagePublicId, + :final expectedElementRevision, + ) => + ElementDeleteOp( + opId: value, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + expectedElementRevision: expectedElementRevision, + ), + ElementReorderOp( + :final elementPublicId, + :final pagePublicId, + :final sortIndex, + :final expectedElementRevision, + ) => + ElementReorderOp( + opId: value, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedElementRevision: expectedElementRevision, + ), + LineupAddOp( + :final lineupPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + :final expectedLineupRevision, + ) => + LineupAddOp( + opId: value, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: expectedLineupRevision, + ), + LineupPatchOp( + :final lineupPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + :final expectedLineupRevision, + ) => + LineupPatchOp( + opId: value, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: expectedLineupRevision, + ), + LineupDeleteOp( + :final lineupPublicId, + :final pagePublicId, + :final expectedLineupRevision, + ) => + LineupDeleteOp( + opId: value, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + expectedLineupRevision: expectedLineupRevision, + ), + LineupReorderOp( + :final lineupPublicId, + :final pagePublicId, + :final sortIndex, + :final expectedLineupRevision, + ) => + LineupReorderOp( + opId: value, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedLineupRevision: expectedLineupRevision, + ), + }; +} + +final class StrategyPatchOp extends StrategyOp { + const StrategyPatchOp({ + required this.opId, + required this.payload, + required this.expectedStrategyRevision, + }); + @override + final String opId; + @override + final CloudPayload payload; + final int expectedStrategyRevision; + @override + StrategyOpType get type => StrategyOpType.strategyPatch; +} + +final class PageAddOp extends StrategyOp { + const PageAddOp({ + required this.opId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + required this.expectedStrategyRevision, + }); + @override + final String opId; + @override + final String pagePublicId; + @override + final CloudPayload payload; + @override + final int sortIndex; + final int expectedStrategyRevision; + @override + StrategyOpType get type => StrategyOpType.pageAdd; +} + +final class PagePatchOp extends StrategyOp { + const PagePatchOp({ + required this.opId, + required this.pagePublicId, + required this.payload, + required this.expectedPageRevision, + }); + @override + final String opId; + @override + final String pagePublicId; + @override + final CloudPayload payload; + final int expectedPageRevision; + @override + StrategyOpType get type => StrategyOpType.pagePatch; +} + +final class PageDeleteOp extends StrategyOp { + const PageDeleteOp({ + required this.opId, + required this.pagePublicId, + required this.expectedStrategyRevision, + }); + @override + final String opId; + @override + final String pagePublicId; + final int expectedStrategyRevision; + @override + StrategyOpType get type => StrategyOpType.pageDelete; +} + +final class PageReorderOp extends StrategyOp { + const PageReorderOp({ + required this.opId, + required this.pagePublicId, + required this.sortIndex, + required this.expectedStrategyRevision, + }); + @override + final String opId; + @override + final String pagePublicId; + @override + final int sortIndex; + final int expectedStrategyRevision; + @override + StrategyOpType get type => StrategyOpType.pageReorder; +} + +final class PageContentPatchOp extends StrategyOp { + const PageContentPatchOp({ + required this.opId, + required this.pagePublicId, + required this.settings, + required this.expectedPageContentRevision, + }); + @override + final String opId; + @override + final String pagePublicId; + final CloudPayload settings; + final int expectedPageContentRevision; + @override + StrategyOpType get type => StrategyOpType.pageContentPatch; +} + +final class ElementAddOp extends StrategyOp { + const ElementAddOp({ + required this.opId, + required this.elementPublicId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + this.expectedElementRevision, + }); + @override + final String opId; + final String elementPublicId; + @override + final String pagePublicId; + @override + final CloudPayload payload; + @override + final int sortIndex; + final int? expectedElementRevision; + @override + StrategyOpType get type => StrategyOpType.elementAdd; +} + +final class ElementPatchOp extends StrategyOp { + const ElementPatchOp({ + required this.opId, + required this.elementPublicId, + required this.expectedElementRevision, + this.pagePublicId, + this.payload, + this.sortIndex, + }); + @override + final String opId; + final String elementPublicId; + @override + final String? pagePublicId; + @override + final CloudPayload? payload; + @override + final int? sortIndex; + final int expectedElementRevision; + @override + StrategyOpType get type => StrategyOpType.elementPatch; +} + +final class ElementDeleteOp extends StrategyOp { + const ElementDeleteOp({ + required this.opId, + required this.elementPublicId, + required this.pagePublicId, + required this.expectedElementRevision, + }); + @override + final String opId; + final String elementPublicId; + @override + final String pagePublicId; + final int expectedElementRevision; + @override + StrategyOpType get type => StrategyOpType.elementDelete; +} + +final class ElementReorderOp extends StrategyOp { + const ElementReorderOp({ + required this.opId, + required this.elementPublicId, + required this.pagePublicId, + required this.sortIndex, + required this.expectedElementRevision, + }); + @override + final String opId; + final String elementPublicId; + @override + final String pagePublicId; + @override + final int sortIndex; + final int expectedElementRevision; + @override + StrategyOpType get type => StrategyOpType.elementReorder; +} + +final class LineupAddOp extends StrategyOp { + const LineupAddOp({ + required this.opId, + required this.lineupPublicId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + this.expectedLineupRevision, + }); + @override + final String opId; + final String lineupPublicId; + @override + final String pagePublicId; + @override + final CloudPayload payload; + @override + final int sortIndex; + final int? expectedLineupRevision; + @override + StrategyOpType get type => StrategyOpType.lineupAdd; +} + +final class LineupPatchOp extends StrategyOp { + const LineupPatchOp({ + required this.opId, + required this.lineupPublicId, + required this.expectedLineupRevision, + this.pagePublicId, + this.payload, + this.sortIndex, + }); + @override + final String opId; + final String lineupPublicId; + @override + final String? pagePublicId; + @override + final CloudPayload? payload; + @override + final int? sortIndex; + final int expectedLineupRevision; + @override + StrategyOpType get type => StrategyOpType.lineupPatch; +} + +final class LineupDeleteOp extends StrategyOp { + const LineupDeleteOp({ + required this.opId, + required this.lineupPublicId, + required this.pagePublicId, + required this.expectedLineupRevision, + }); + @override + final String opId; + final String lineupPublicId; + @override + final String pagePublicId; + final int expectedLineupRevision; + @override + StrategyOpType get type => StrategyOpType.lineupDelete; +} + +final class LineupReorderOp extends StrategyOp { + const LineupReorderOp({ + required this.opId, + required this.lineupPublicId, + required this.pagePublicId, + required this.sortIndex, + required this.expectedLineupRevision, + }); + @override + final String opId; + final String lineupPublicId; + @override + final String pagePublicId; + @override + final int sortIndex; + final int expectedLineupRevision; + @override + StrategyOpType get type => StrategyOpType.lineupReorder; +} + +Map _requiredMap(Object? value, String field) { + final map = _optionalMap(value); + if (map == null) throw FormatException('Op $field must be an object'); + return map; +} + +Map? _optionalMap(Object? value) { + if (value == null) return null; + if (value is Map) return value; + if (value is Map) return Map.from(value); + throw const FormatException('Op value must be an object'); +} + +int _requiredInt(Object? value) { + if (value is num) return value.toInt(); + throw const FormatException('Op revision or index must be a number'); +} + +int serializedCloudOperationUtf8Bytes(StrategyOp op) => + serializedConvexValueUtf8Bytes(op.toConvexJson()); + +bool cloudOperationExceedsPolicy(StrategyOp op) { + final value = op.toConvexJson(); + return serializedConvexValueUtf8Bytes(value) > maxCloudOperationBytes || + _cloudValueExceedsArrayPolicy(value); +} + +bool _cloudValueExceedsArrayPolicy(Object? value) { + if (value is List) { + return value.length > maxCloudArrayEntries || + value.any(_cloudValueExceedsArrayPolicy); + } + if (value is Map) { + return value.values.any(_cloudValueExceedsArrayPolicy); + } + return false; +} + +int serializedCloudBatchUtf8Bytes({ + required String strategyPublicId, + required String clientId, + required Iterable ops, +}) { + return serializedConvexValueUtf8Bytes({ + 'strategyPublicId': strategyPublicId, + 'clientId': clientId, + 'clientProtocolVersion': currentCloudProtocolVersion, + 'ops': ops.map((op) => op.toConvexJson()).toList(growable: false), + }); +} + +class PendingOp { + const PendingOp({ + required this.op, + required this.clientId, + this.attempts = 0, + this.lastAttemptAt, + }); + + final StrategyOp op; + final String clientId; + final int attempts; + final DateTime? lastAttemptAt; + + PendingOp incrementAttempt() { + return PendingOp( + op: op, + clientId: clientId, + attempts: attempts + 1, + lastAttemptAt: DateTime.now(), + ); + } +} + +enum OpRejectionReason { + alreadyExists('already_exists'), + deleted('deleted'), + elementStrategyMismatch('element_strategy_mismatch'), + lineupStrategyMismatch('lineup_strategy_mismatch'), + missingExpectedRevision('missing_expected_revision'), + notFound('not_found'), + pageStrategyMismatch('page_strategy_mismatch'), + revisionMismatch('revision_mismatch'); + + const OpRejectionReason(this.wireName); + final String wireName; + + static OpRejectionReason fromWireName(String value) => + values.firstWhere((reason) => reason.wireName == value); +} + +sealed class CurrentOpSnapshot { + const CurrentOpSnapshot({required this.revision, required this.value}); + final int revision; + final CloudPayload value; + + factory CurrentOpSnapshot.fromJson(Map json) { + final revision = _requiredInt(json['revision']); + final value = _requiredMap(json['value'], 'current.value'); + return switch (json['type']) { + 'strategy' => StrategyCurrentSnapshot(revision: revision, value: value), + 'page' => PageCurrentSnapshot(revision: revision, value: value), + 'pageContent' => + PageContentCurrentSnapshot(revision: revision, value: value), + 'element' => ElementCurrentSnapshot(revision: revision, value: value), + 'lineup' => LineupCurrentSnapshot(revision: revision, value: value), + final Object? type => throw FormatException( + 'Unknown current op snapshot type: $type', + ), + }; + } +} + +final class StrategyCurrentSnapshot extends CurrentOpSnapshot { + const StrategyCurrentSnapshot( + {required super.revision, required super.value}); +} + +final class PageCurrentSnapshot extends CurrentOpSnapshot { + const PageCurrentSnapshot({required super.revision, required super.value}); +} + +final class PageContentCurrentSnapshot extends CurrentOpSnapshot { + const PageContentCurrentSnapshot({ + required super.revision, + required super.value, + }); +} + +final class ElementCurrentSnapshot extends CurrentOpSnapshot { + const ElementCurrentSnapshot({required super.revision, required super.value}); +} + +final class LineupCurrentSnapshot extends CurrentOpSnapshot { + const LineupCurrentSnapshot({required super.revision, required super.value}); +} + +sealed class OpAck { + const OpAck(); + + String get opId; + String get status => switch (this) { + AppliedOpAck() => 'applied', + NoopOpAck() => 'noop', + RejectedOpAck() => 'rejected', + FailedOpAck() => 'failed', + }; + bool get isAck => this is AppliedOpAck || this is NoopOpAck; + String? get reason => switch (this) { + RejectedOpAck(:final rejectionReason) => rejectionReason.wireName, + FailedOpAck(:final message) => message, + AppliedOpAck() || NoopOpAck() => null, + }; + int? get appliedRevision => switch (this) { + AppliedOpAck(:final revision) => revision, + NoopOpAck(:final currentRevision) => currentRevision, + RejectedOpAck() || FailedOpAck() => null, + }; + int? get latestRevision => switch (this) { + RejectedOpAck(:final current) => current?.revision, + AppliedOpAck() || NoopOpAck() || FailedOpAck() => null, + }; + CloudPayload? get latestPayload => switch (this) { + RejectedOpAck(:final current) => current?.value, + AppliedOpAck() || NoopOpAck() || FailedOpAck() => null, + }; + + factory OpAck.fromJson(Map json) { + final opId = json['opId'] as String; + return switch (json['status']) { + 'applied' => AppliedOpAck( + opId: opId, + revision: _requiredInt(json['appliedRevision']), + ), + 'noop' => NoopOpAck( + opId: opId, + currentRevision: (json['currentRevision'] as num?)?.toInt(), + ), + 'rejected' => RejectedOpAck( + opId: opId, + rejectionReason: + OpRejectionReason.fromWireName(json['reason'] as String), + current: json['current'] == null + ? null + : CurrentOpSnapshot.fromJson( + Map.from(json['current'] as Map), + ), + ), + 'failed' => FailedOpAck( + opId: opId, + code: json['code'] as String, + rawCode: json['rawCode'] as String, + message: json['message'] as String, + ), + final Object? status => + throw FormatException('Unknown op result status: $status'), + }; + } +} + +final class AppliedOpAck extends OpAck { + const AppliedOpAck({required this.opId, required this.revision}); + @override + final String opId; + final int revision; +} + +final class NoopOpAck extends OpAck { + const NoopOpAck({required this.opId, this.currentRevision}); + @override + final String opId; + final int? currentRevision; +} + +final class RejectedOpAck extends OpAck { + const RejectedOpAck({ + required this.opId, + required this.rejectionReason, + this.current, + }); + @override + final String opId; + final OpRejectionReason rejectionReason; + final CurrentOpSnapshot? current; +} + +final class FailedOpAck extends OpAck { + const FailedOpAck({ + required this.opId, + required this.code, + required this.rawCode, + required this.message, + }); + @override + final String opId; + final String code; + final String rawCode; + final String message; +} + +enum ConflictResolutionType { rebase, drop, retry } + +class ConflictResolution { + const ConflictResolution({ + required this.type, + required this.opId, + this.message, + this.serverPayload, + this.serverRevision, + }); + + final ConflictResolutionType type; + final String opId; + final String? message; + final Map? serverPayload; + final int? serverRevision; +} + +class RemoteStrategyHeader { + const RemoteStrategyHeader({ + required this.publicId, + required this.name, + required this.mapData, + required this.revision, + required this.createdAt, + required this.updatedAt, + this.themeProfileId, + this.themeOverridePalette, + this.role, + }); + + final String publicId; + final String name; + final String mapData; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; + final String? themeProfileId; + final CloudPayload? themeOverridePalette; + final String? role; +} + +class RemotePage { + const RemotePage({ + required this.publicId, + required this.strategyPublicId, + required this.name, + this.isAutoNamed, + required this.sortIndex, + required this.isAttack, + required this.revision, + required this.createdAt, + required this.updatedAt, + }); + + final String publicId; + final String strategyPublicId; + final String name; + final bool? isAutoNamed; + final int sortIndex; + final bool isAttack; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; +} + +class RemotePageContent { + const RemotePageContent({ + required this.revision, + required this.createdAt, + required this.updatedAt, + this.settings, + }); + + final CloudPayload? settings; + final int revision; + final DateTime createdAt; + final DateTime updatedAt; +} + +class RemoteElement { + const RemoteElement({ + required this.publicId, + required this.strategyPublicId, + required this.pagePublicId, + required this.elementType, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final String publicId; + final String strategyPublicId; + final String pagePublicId; + final String elementType; + final CloudPayload payload; + final int sortIndex; + final int revision; + final bool deleted; + + Map decodedPayload() => cloudPayloadData(payload); +} + +class RemoteLineup { + const RemoteLineup({ + required this.publicId, + required this.strategyPublicId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final String publicId; + final String strategyPublicId; + final String pagePublicId; + final CloudPayload payload; + final int sortIndex; + final int revision; + final bool deleted; +} + +class RemoteImageAsset { + const RemoteImageAsset({ + required this.publicId, + required this.fileExtension, + required this.width, + required this.height, + required this.url, + required this.legacyStoragePath, + this.provider = 'convex', + this.uploadStatus = 'active', + this.mimeType, + this.byteSize, + this.uploadedAt, + }); + + final String publicId; + final String provider; + final String uploadStatus; + final String fileExtension; + final String? mimeType; + final int? width; + final int? height; + final int? byteSize; + final DateTime? uploadedAt; + final String? url; + final String? legacyStoragePath; +} + +class RemoteStrategyShell { + const RemoteStrategyShell({ + required this.header, + required this.pages, + }); + + final RemoteStrategyHeader header; + final List pages; +} + +class RemotePageSnapshot { + const RemotePageSnapshot({ + required this.page, + required this.content, + required this.elements, + required this.lineups, + required this.assetsById, + }); + + final RemotePage page; + final RemotePageContent content; + final List elements; + final List lineups; + final Map assetsById; +} + +/// The bounded live editor state: one strategy shell and at most one page body. +class RemoteEditorSnapshot { + const RemoteEditorSnapshot({ + required this.shell, + required this.activePage, + }); + + final RemoteStrategyShell shell; + final RemotePageSnapshot? activePage; + + RemoteStrategyHeader get header => shell.header; + List get pages => shell.pages; + Map> get elementsByPage => activePage == null + ? const >{} + : >{ + activePage!.page.publicId: activePage!.elements, + }; + Map> get lineupsByPage => activePage == null + ? const >{} + : >{ + activePage!.page.publicId: activePage!.lineups, + }; + Map get assetsById => + activePage?.assetsById ?? const {}; + + RemoteEditorSnapshot copyWith({ + RemoteStrategyShell? shell, + RemotePageSnapshot? activePage, + bool clearActivePage = false, + }) { + return RemoteEditorSnapshot( + shell: shell ?? this.shell, + activePage: clearActivePage ? null : (activePage ?? this.activePage), + ); + } +} + +class RemoteFullPage { + const RemoteFullPage({required this.page, required this.content}); + + final RemotePage page; + final RemotePageContent content; +} + +/// A one-shot whole-strategy value used only by explicit export/import flows. +class RemoteFullStrategySnapshot { + const RemoteFullStrategySnapshot({ + required this.header, + required this.pages, + required this.elementsByPage, + required this.lineupsByPage, + required this.assetsById, + }); + + final RemoteStrategyHeader header; + final List pages; + final Map> elementsByPage; + final Map> lineupsByPage; + final Map assetsById; + + static Map> groupElementsByPage( + Iterable elements, + ) { + final grouped = >{}; + for (final element in elements) { + (grouped[element.pagePublicId] ??= []).add(element); + } + for (final elements in grouped.values) { + elements.sortBySortIndex((item) => item.sortIndex); + } + return grouped; + } + + static Map> groupLineupsByPage( + Iterable lineups, + ) { + final grouped = >{}; + for (final lineup in lineups) { + (grouped[lineup.pagePublicId] ??= []).add(lineup); + } + for (final lineups in grouped.values) { + lineups.sortBySortIndex((item) => item.sortIndex); + } + return grouped; + } +} + +/// A page in the server's trash that can still be restored, as Recently +/// deleted lists it. +class TrashedPage { + const TrashedPage({ + required this.pageId, + required this.name, + required this.deletedAt, + required this.restorableUntil, + required this.deletedByName, + required this.deletedByYou, + }); + + final String pageId; + final String name; + final DateTime deletedAt; + + /// When the page is purged for good. + final DateTime restorableUntil; + + /// Who deleted it, or null when that is not known. + final String? deletedByName; + final bool deletedByYou; +} + +class ShareLinkSummary { + const ShareLinkSummary({ + required this.token, + required this.role, + required this.createdAt, + this.revokedAt, + }); + + final String token; + final String role; + final DateTime createdAt; + final DateTime? revokedAt; + + bool get isRevoked => revokedAt != null; +} + +class ShareRedemption { + const ShareRedemption({ + required this.targetType, + required this.role, + this.folderPublicId, + this.strategyPublicId, + this.alreadyHadAccess = false, + }); + + final String targetType; + final String role; + final String? folderPublicId; + final String? strategyPublicId; + + /// The caller could already open the target (owner, or already a + /// collaborator at this role or higher): the link granted nothing new. + final bool alreadyHadAccess; + + bool get isOwner => role == 'owner'; +} diff --git a/lib/collab/convex_client.dart b/lib/collab/convex_client.dart new file mode 100644 index 00000000..5c58e5f2 --- /dev/null +++ b/lib/collab/convex_client.dart @@ -0,0 +1,3 @@ +export 'src/convex_client_types.dart'; +export 'src/convex_client_native.dart' + if (dart.library.js_interop) 'src/convex_client_web.dart'; diff --git a/lib/collab/convex_payload_codecs.dart b/lib/collab/convex_payload_codecs.dart new file mode 100644 index 00000000..4d28deac --- /dev/null +++ b/lib/collab/convex_payload_codecs.dart @@ -0,0 +1,170 @@ +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +export 'package:icarus/collab/collab_models.dart' show CloudPayload; + +final class ConvexPayload { + const ConvexPayload(this.tag); + + final String tag; +} + +abstract interface class ConvexPayloadCodec { + const ConvexPayloadCodec(); + + ConvexValue encode(T value); + + T decode(ConvexValue value); +} + +CloudPayload _decodePayload(ConvexValue value, String expectedTag) { + if (value is! ConvexObject) { + throw FormatException('Expected $expectedTag payload object'); + } + final decoded = value.toDart(); + if (decoded['kind'] != expectedTag) { + throw FormatException( + 'Expected payload kind $expectedTag, received ${decoded['kind']}', + ); + } + if (decoded['payloadVersion'] is! num || decoded['data'] is! Map) { + throw FormatException('Invalid $expectedTag payload envelope'); + } + return Map.from( + canonicalCloudJsonValue(decoded) as Map, + ); +} + +ConvexValue _encodePayload(CloudPayload value, String expectedTag) { + if (value['kind'] != expectedTag) { + throw FormatException( + 'Expected payload kind $expectedTag, received ${value['kind']}', + ); + } + return ConvexValue.fromDart(canonicalCloudJsonValue(value)); +} + +@ConvexPayload('agent') +final class AgentConvexCodec implements ConvexPayloadCodec { + const AgentConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'agent'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'agent'); +} + +@ConvexPayload('ability') +final class AbilityConvexCodec implements ConvexPayloadCodec { + const AbilityConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'ability'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'ability'); +} + +@ConvexPayload('drawing') +final class DrawingConvexCodec implements ConvexPayloadCodec { + const DrawingConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'drawing'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'drawing'); +} + +@ConvexPayload('text') +final class TextConvexCodec implements ConvexPayloadCodec { + const TextConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'text'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'text'); +} + +@ConvexPayload('image') +final class ImageConvexCodec implements ConvexPayloadCodec { + const ImageConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'image'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'image'); +} + +@ConvexPayload('utility') +final class UtilityConvexCodec implements ConvexPayloadCodec { + const UtilityConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'utility'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'utility'); +} + +// One lineup group: the lineups on a page joined through shared spots. +@ConvexPayload('lineups') +final class LineupsConvexCodec implements ConvexPayloadCodec { + const LineupsConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'lineups'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'lineups'); +} + +// The origin, landing and link rows of protocol 4. The server still takes +// them as lineup op arguments, so an old client reaches its protocol gate +// instead of failing argument validation, and refuses them in the handler +// (see lineupOpPayloadValidator in convex/lib/payloadValidators.ts). This +// build never sends them: an outbox record holding one waits in attention +// (see isRetiredCloudLineupOp). + +@ConvexPayload('lineupOrigin') +final class LineupOriginConvexCodec + implements ConvexPayloadCodec { + const LineupOriginConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => + _decodePayload(value, 'lineupOrigin'); + + @override + ConvexValue encode(CloudPayload value) => + _encodePayload(value, 'lineupOrigin'); +} + +@ConvexPayload('lineupLanding') +final class LineupLandingConvexCodec + implements ConvexPayloadCodec { + const LineupLandingConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => + _decodePayload(value, 'lineupLanding'); + + @override + ConvexValue encode(CloudPayload value) => + _encodePayload(value, 'lineupLanding'); +} + +@ConvexPayload('lineupLink') +final class LineupLinkConvexCodec implements ConvexPayloadCodec { + const LineupLinkConvexCodec(); + + @override + CloudPayload decode(ConvexValue value) => _decodePayload(value, 'lineupLink'); + + @override + ConvexValue encode(CloudPayload value) => _encodePayload(value, 'lineupLink'); +} diff --git a/lib/collab/convex_strategy_repository.dart b/lib/collab/convex_strategy_repository.dart new file mode 100644 index 00000000..89c6330b --- /dev/null +++ b/lib/collab/convex_strategy_repository.dart @@ -0,0 +1,1125 @@ +import 'dart:math' show min; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_payload_upgrade.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/collab/transport/convex_transport_adapter.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +final convexStrategyRepositoryProvider = Provider( + (ref) => ConvexStrategyRepository.fromClient(ConvexClient.instance), +); + +class ConvexStrategyRepository { + ConvexStrategyRepository(this._api); + + factory ConvexStrategyRepository.fromClient(ConvexClient client) => + ConvexStrategyRepository( + IcarusConvexApi(PlatformConvexTransport(client)), + ); + + final IcarusConvexApi _api; + + /// Permission to join [strategyPublicId]'s presence room, or null when this + /// deployment has no presence service. + Future issueRoomPass(String strategyPublicId) async { + final result = await _api.presence.issueRoomPass( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + ); + if (result == null) return null; + return RoomPass( + url: Uri.parse(result.url), + pass: result.pass, + expiresAt: _dateTime(result.expiresAt), + ); + } + + /// Whether the server accepts this build's cloud protocol: false while it + /// refuses it. Throws when it cannot tell, offline or from a deployment + /// that predates the question. + Future serverAcceptsCloudProtocol() async { + try { + await _api.health + .ping( + clientProtocolVersion: ConvexOptional.present( + currentCloudProtocolVersion.toDouble(), + ), + ) + .fetch(); + return true; + } catch (error) { + if (isClientUpgradeRequiredError(error)) return false; + rethrow; + } + } + + Future ensureCurrentUser() async { + await _api.users.ensureCurrentUser( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + ); + } + + Stream> watchAllFolders() { + return _api.folders + .listTree( + scope: const ConvexOptional.present(FoldersListTreeArgsScope.all), + ) + .watch() + .map( + (folders) => folders.map(_folderEntry).toList(growable: false), + ); + } + + Stream> watchStrategiesForFolder( + String? folderPublicId, { + String scope = 'owned', + }) { + return _api.strategies + .listForFolder( + folderPublicId: _optional(folderPublicId), + scope: ConvexOptional.present(_folderScope(scope)), + ) + .watch() + .map( + (strategies) => + strategies.map(_strategyEntry).toList(growable: false), + ); + } + + Stream> watchSharedStrategies() { + return _api.strategies.listSharedWithMe().watch().map( + (strategies) => + strategies.map(_strategyEntry).toList(growable: false), + ); + } + + /// [shareToken] is a share link the reader holds but has not redeemed + /// (see [resolveSharedStrategy]); with it, a signed-out reader can view. + Future fetchShell( + String strategyPublicId, { + String? shareToken, + }) async { + return _strategyShell( + await _api.strategy + .getShell( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + shareToken: _optional(shareToken), + ) + .fetch(), + ); + } + + /// Whether the server no longer has [strategyPublicId]: its shell query + /// fails with NOT_FOUND, which getShell raises only for a missing strategy + /// (deletion removes the row). Any other failure (offline, auth) is + /// rethrown, so "could not tell" is never read as "deleted". + Future strategyIsDeleted(String strategyPublicId) async { + try { + await fetchShell(strategyPublicId); + return false; + } catch (error) { + if (isTypedConvexNotFoundError(error)) return true; + rethrow; + } + } + + Stream watchShell( + String strategyPublicId, { + String? shareToken, + }) { + return _api.strategy + .getShell( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + shareToken: _optional(shareToken), + ) + .watch() + .map(_strategyShell); + } + + Future fetchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + String? shareToken, + }) async { + return _pageSnapshot( + await _api.page + .getSnapshot( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + shareToken: _optional(shareToken), + ) + .fetch(), + ); + } + + Stream watchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + String? shareToken, + }) { + return _api.page + .getSnapshot( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + shareToken: _optional(shareToken), + ) + .watch() + .map(_pageSnapshot); + } + + /// Which of [assetPublicIds] the strategy's content shows on the server, + /// pages in its trash included, or null while the server cannot tell yet. + /// Asked a batch at a time. + Future?> fetchReferencedAssetIds( + String strategyPublicId, + Iterable assetPublicIds, + ) async { + final asked = assetPublicIds.toSet().toList(growable: false); + final referenced = {}; + for (var start = 0; start < asked.length; start += _referencedIdsBatch) { + final ids = await _api.images + .listReferencedAssetIds( + strategyPublicId: strategyPublicId, + assetPublicIds: asked.sublist( + start, + min(start + _referencedIdsBatch, asked.length), + ), + ) + .fetch(); + if (ids == null) return null; + referenced.addAll(ids); + } + return referenced; + } + + /// The server's MAX_REFERENCED_ASSET_IDS_PER_QUERY. + static const _referencedIdsBatch = 100; + + /// [shareToken]: see [fetchShell]. + Future fetchFullSnapshot( + String strategyPublicId, { + String? shareToken, + }) async { + return upgradeRemoteFullSnapshot(_fullSnapshot( + await _api.strategy + .getFullSnapshot( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + shareToken: _optional(shareToken), + // This client checks image references apart, so it can take a + // snapshot without the pages in the server's trash. + acceptsTrashedPagesLeftOut: const ConvexOptional.present(true), + ) + .fetch(), + )); + } + + Future generateImageUploadUrl({ + required String strategyPublicId, + required String assetPublicId, + required String mimeType, + required String fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + final result = await _api.images.generateUploadUrl( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + mimeType: mimeType, + fileExtension: fileExtension, + byteSize: _optionalNumber(byteSize), + width: _optionalNumber(width), + height: _optionalNumber(height), + ); + return CloudImageUploadIntent( + provider: result.provider.wireName, + uploadId: result.uploadId, + objectKey: result.objectKey, + uploadUrl: result.uploadUrl, + requiredHeaders: result.requiredHeaders, + expiresAt: _dateTime(result.expiresAt), + maxBytes: result.maxBytes.toInt(), + ); + } + + Future completeImageUpload({ + required String strategyPublicId, + required String assetPublicId, + String? provider, + String? uploadId, + String? objectKey, + String? storageId, + String? etag, + String? mimeType, + String? fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + await _api.images.completeUpload( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + provider: provider == null + ? const ConvexOptional.absent() + : ConvexOptional.present(_imageProvider(provider)), + uploadId: _optional(uploadId), + objectKey: _optional(objectKey), + storageId: _optional(storageId), + etag: _optional(etag), + mimeType: _optional(mimeType), + fileExtension: _optional(fileExtension), + byteSize: _optionalNumber(byteSize), + width: _optionalNumber(width), + height: _optionalNumber(height), + ); + } + + Future getImageAssetUrl({ + required String strategyPublicId, + required String assetPublicId, + String? shareToken, + }) async { + final result = await _api.images + .getAssetUrl( + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + shareToken: _optional(shareToken), + ) + .fetch(); + return result.url; + } + + /// Sends [ops] to [strategyPublicId]. With [accountSubject], the account + /// the ops belong to (its auth user id, as the outbox keys it), the + /// server refuses the whole batch with FORBIDDEN unless that account is + /// the one signed in when the batch arrives, which may be later than when + /// it was sent. + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + if (ops.isEmpty) return const []; + for (final op in ops) { + if (cloudOperationExceedsPolicy(op)) { + throw StateError(cloudOperationTooLargeMessage); + } + } + if (serializedCloudBatchUtf8Bytes( + strategyPublicId: strategyPublicId, + clientId: clientId, + ops: ops, + ) > + maxCloudBatchBytes) { + throw StateError('Cloud operation batch exceeds the payload policy.'); + } + + final typedOps = ops.indexed + .map( + (entry) => OpsApplyBatchArgsOpsItem.decode( + ConvexValue.fromDart(entry.$2.toConvexJson()), + 'ops[${entry.$1}]', + ), + ) + .toList(growable: false); + final result = await _api.ops.applyBatch( + strategyPublicId: strategyPublicId, + clientId: clientId, + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + ops: typedOps, + accountSubject: _optional(accountSubject), + // This client restores deleted pages, so it sends such a delete again. + checkTrashedPageDeletes: const ConvexOptional.present(true), + ); + return result.results.map(_opAck).toList(growable: false); + } + + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + String? iconFontPackage, + String? color, + int? customColorValue, + }) async { + await _api.folders.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + publicId: publicId, + name: name, + parentFolderPublicId: _optional(parentFolderPublicId), + iconId: _optionalNumber(iconId), + iconCodePoint: _optionalNumber(iconCodePoint), + iconFontFamily: _optional(iconFontFamily), + iconFontPackage: _optional(iconFontPackage), + color: _optional(color), + customColorValue: _optionalNumber(customColorValue), + ); + } + + Future updateFolder({ + required String folderPublicId, + String? name, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + String? iconFontPackage, + bool clearIconFontFamily = false, + bool clearIconFontPackage = false, + String? color, + int? customColorValue, + bool clearCustomColorValue = false, + }) async { + await _api.folders.update( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + folderPublicId: folderPublicId, + name: _optional(name), + iconId: _optionalNumber(iconId), + iconCodePoint: _optionalNumber(iconCodePoint), + iconFontFamily: _optional(iconFontFamily), + iconFontPackage: _optional(iconFontPackage), + clearIconFontFamily: _presentWhenTrue(clearIconFontFamily), + clearIconFontPackage: _presentWhenTrue(clearIconFontPackage), + color: _optional(color), + customColorValue: _optionalNumber(customColorValue), + clearCustomColorValue: _presentWhenTrue(clearCustomColorValue), + ); + } + + Future deleteFolder(String folderPublicId) async { + await _api.folders.delete( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + folderPublicId: folderPublicId, + ); + } + + Future moveFolder({ + required String folderPublicId, + String? parentFolderPublicId, + }) async { + await _api.folders.move( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + folderPublicId: folderPublicId, + parentFolderPublicId: _optional(parentFolderPublicId), + ); + } + + Future createStrategy({ + required String publicId, + required String name, + required String mapData, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }) async { + await _api.strategies.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + publicId: publicId, + name: name, + mapData: mapData, + folderPublicId: _optional(folderPublicId), + themeProfileId: _optional(themeProfileId), + themeOverridePalette: _themePalette(themeOverridePalette), + ); + } + + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + bool? initialPageIsAutoNamed, + required bool initialPageIsAttack, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + Map? initialPageSettings, + }) async { + await _api.strategies.createWithInitialPage( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + publicId: publicId, + name: name, + mapData: mapData, + initialPagePublicId: initialPagePublicId, + initialPageName: initialPageName, + initialPageIsAutoNamed: _optional(initialPageIsAutoNamed), + initialPageIsAttack: initialPageIsAttack, + folderPublicId: _optional(folderPublicId), + themeProfileId: _optional(themeProfileId), + themeOverridePalette: _themePalette(themeOverridePalette), + initialPageSettings: _pageSettings(initialPageSettings), + ); + } + + /// Copies [sourceStrategyPublicId] into the signed-in user's library as + /// [publicId]: pages, content, and images, in one server transaction. + /// Retrying with the same [publicId] reuses the copy already made. + Future duplicateStrategy({ + required String sourceStrategyPublicId, + required String publicId, + required String name, + String? folderPublicId, + }) async { + await _api.strategies.duplicate( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + sourceStrategyPublicId: sourceStrategyPublicId, + publicId: publicId, + name: name, + folderPublicId: _optional(folderPublicId), + ); + } + + Future updateStrategyName({ + required String strategyPublicId, + required String name, + required int expectedRevision, + }) async { + await _api.strategies.update( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + name: ConvexOptional.present(name), + expectedRevision: expectedRevision.toDouble(), + ); + } + + Future deleteStrategy({ + required String strategyPublicId, + required int expectedRevision, + }) async { + await _api.strategies.delete( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + expectedRevision: expectedRevision.toDouble(), + ); + } + + Future moveStrategy({ + required String strategyPublicId, + required String? folderPublicId, + required int expectedRevision, + }) async { + await _api.strategies.move( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + folderPublicId: _optional(folderPublicId), + expectedRevision: expectedRevision.toDouble(), + ); + } + + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + bool? isAutoNamed, + required int sortIndex, + required bool isAttack, + required int expectedRevision, + Map? settings, + }) async { + await _api.pages.add( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + name: name, + isAutoNamed: _optional(isAutoNamed), + sortIndex: sortIndex.toDouble(), + isAttack: isAttack, + expectedRevision: expectedRevision.toDouble(), + settings: _pageSettings(settings), + ); + } + + /// Brings a deleted page back from the server's trash. Throws NOT_FOUND + /// (see [isTypedConvexNotFoundError]) once it can no longer be restored. + Future restorePage({ + required String strategyPublicId, + required String pagePublicId, + }) async { + await _api.pages.restore( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + ); + } + + /// The strategy's deleted pages that can still be restored, most + /// recently deleted first. + Future> listTrashedPages(String strategyPublicId) async { + final pages = await _api.pages + .listTrashed(strategyPublicId: strategyPublicId) + .fetch(); + return [ + for (final page in pages) + TrashedPage( + pageId: page.publicId, + name: page.name, + deletedAt: _dateTime(page.deletedAt), + restorableUntil: _dateTime(page.restorableUntil), + deletedByName: page.deletedByName, + deletedByYou: page.deletedByYou, + ), + ]; + } + + Future> listShareLinks({ + required String targetType, + required String targetPublicId, + }) async { + final result = await _api.shares + .list( + targetType: _shareTargetType(targetType), + targetPublicId: targetPublicId, + ) + .fetch(); + return result + .map( + (share) => ShareLinkSummary( + token: share.token, + role: share.role.wireName, + createdAt: _dateTime(share.createdAt), + revokedAt: + share.revokedAt == null ? null : _dateTime(share.revokedAt!), + ), + ) + .toList(growable: false); + } + + Future createShareLink({ + required String targetType, + required String targetPublicId, + required String token, + required String role, + }) async { + await _api.shares.create( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + targetType: _shareTargetType(targetType), + targetPublicId: targetPublicId, + token: token, + role: _shareRole(role), + ); + } + + Future revokeShareLink({ + required String targetType, + required String targetPublicId, + required String token, + }) async { + await _api.shares.revoke( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + targetType: _shareTargetType(targetType), + targetPublicId: targetPublicId, + token: token, + ); + } + + /// The strategy [token] shares, or null when it shares a folder. Needs no + /// account. Throws NOT_FOUND for a link that never existed and + /// SHARE_LINK_REVOKED for one its owner disabled. + Future resolveSharedStrategy(String token) async { + return switch (await _api.shares.resolve(token: token).fetch()) { + SharesResolveResultStrategy(:final strategyPublicId) => strategyPublicId, + SharesResolveResultFolder() => null, + }; + } + + Future redeemShareLink(String token) async { + final result = await _api.shares.redeem( + clientProtocolVersion: currentCloudProtocolVersion.toDouble(), + token: token, + ); + return switch (result) { + SharesRedeemResultFolder( + :final folderPublicId, + :final role, + :final alreadyHadAccess, + ) => + ShareRedemption( + targetType: 'folder', + folderPublicId: folderPublicId, + role: role.wireName, + alreadyHadAccess: _alreadyHadAccess(alreadyHadAccess, role.wireName), + ), + SharesRedeemResultStrategy( + :final folderPublicId, + :final strategyPublicId, + :final role, + :final alreadyHadAccess, + ) => + ShareRedemption( + targetType: 'strategy', + folderPublicId: folderPublicId, + strategyPublicId: strategyPublicId, + role: role.wireName, + alreadyHadAccess: _alreadyHadAccess(alreadyHadAccess, role.wireName), + ), + }; + } + + /// The server's answer, or, from a deployment that predates the field, + /// the part it can still tell us: an owner always had access. + static bool _alreadyHadAccess(ConvexOptional reported, String role) { + return reported.isPresent ? reported.value : role == 'owner'; + } +} + +bool isTypedConvexUnauthenticatedError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.unauthenticated) || + (error is ConvexClientFunctionError && + error.rawCode == ConvexErrorCode.unauthenticated.wireName); +} + +bool isTypedConvexNotFoundError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.notFound) || + (error is ConvexClientFunctionError && + error.rawCode == ConvexErrorCode.notFound.wireName); +} + +bool isShareLinkRevokedError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.shareLinkRevoked) || + (error is ConvexClientFunctionError && + error.rawCode == ConvexErrorCode.shareLinkRevoked.wireName); +} + +bool isTypedConvexForbiddenError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.forbidden) || + (error is ConvexClientFunctionError && + error.rawCode == ConvexErrorCode.forbidden.wireName); +} + +/// The server refused a duplicate because the strategy holds more content +/// than one copy can move within Convex's per-transaction limits. +bool isStrategyTooLargeToDuplicateError(Object error) { + return (error is ConvexFunctionException && + error.code == ConvexErrorCode.strategyTooLargeToDuplicate) || + (error is ConvexClientFunctionError && + error.rawCode == + ConvexErrorCode.strategyTooLargeToDuplicate.wireName); +} + +bool isMissingImageUploadIntentError(Object error) => + error is ConvexFunctionException && + error.code == ConvexErrorCode.uploadIntentNotFound; + +CloudFolderEntry _folderEntry(FoldersListTreeResultItem folder) { + return ( + folder: Folder( + id: folder.publicId, + name: folder.name, + dateCreated: _dateTime(folder.createdAt), + parentID: folder.parentFolderPublicId, + iconId: folderIconIdFromCloud( + iconId: folder.iconId?.toInt(), + codePoint: folder.iconCodePoint?.toInt(), + fontFamily: folder.iconFontFamily, + fontPackage: folder.iconFontPackage, + ), + color: folderColorFromWireName(folder.color), + customColor: folderCustomColorFromCloud(folder.customColorValue?.toInt()), + ), + role: folder.role.wireName, + strategyCount: folder.strategyCount.toInt(), + mapPeeks: [ + for (final wireName in folder.mapPeeks) + if (_mapValueOrNull(wireName) case final map?) map, + ], + agentTypes: [ + for (final name in folder.agentTypes) + if (_agentTypeOrNull(name) case final type?) type, + ], + ); +} + +/// Unknown wire names are skipped: a peek at a map or agent this build does +/// not know yet must not break the folder list. +MapValue? _mapValueOrNull(String wireName) { + for (final entry in Maps.mapNames.entries) { + if (entry.value == wireName) return entry.key; + } + return null; +} + +AgentType? _agentTypeOrNull(String name) { + for (final type in AgentType.values) { + if (type.name == name) return type; + } + return null; +} + +CloudStrategyEntry _strategyEntry( + StrategiesListForFolderResultItem strategy, +) { + return ( + strategy: StrategyData( + id: strategy.publicId, + name: strategy.name, + mapData: _mapValue(strategy.mapData), + versionNumber: Settings.versionNumber, + lastEdited: _dateTime(strategy.updatedAt), + createdAt: _dateTime(strategy.createdAt), + folderID: strategy.folderPublicId, + themeProfileId: strategy.themeProfileId, + themeOverridePalette: _mapThemePalette(strategy.themeOverridePalette), + ), + revision: strategy.revision.toInt(), + role: strategy.role.wireName, + attackLabel: strategy.attackLabel.wireName, + ); +} + +MapValue _mapValue(String wireName) { + for (final entry in Maps.mapNames.entries) { + if (entry.value == wireName) return entry.key; + } + throw ConvexDecodingException( + 'strategies.listForFolder.result.mapData', + 'unknown Icarus map $wireName', + ); +} + +MapThemePalette? _mapThemePalette( + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette? palette, +) { + final value = _themePaletteValue(palette); + return value == null ? null : MapThemePalette.fromJson(value); +} + +RemoteStrategyShell _strategyShell(StrategyGetShellResult result) { + return RemoteStrategyShell( + header: _strategyHeader(result.header), + pages: result.pages.map(_page).toList(growable: false), + ); +} + +RemotePageSnapshot _pageSnapshot(PageGetSnapshotResult result) { + final assets = result.assets.map(_imageAsset).toList(growable: false); + return RemotePageSnapshot( + page: _page(result.page), + content: _pageContent(result.content), + elements: result.elements.map(_element).toList(growable: false), + lineups: result.lineups.map(_lineup).toList(growable: false), + assetsById: {for (final asset in assets) asset.publicId: asset}, + ); +} + +RemoteFullStrategySnapshot _fullSnapshot(StrategyGetFullSnapshotResult result) { + final pages = result.pages.map((page) { + return RemoteFullPage( + page: RemotePage( + publicId: page.publicId, + strategyPublicId: page.strategyPublicId, + name: page.name, + isAutoNamed: _optionalValue(page.isAutoNamed), + sortIndex: page.sortIndex.toInt(), + isAttack: page.isAttack, + revision: page.revision.toInt(), + createdAt: _dateTime(page.createdAt), + updatedAt: _dateTime(page.updatedAt), + ), + content: RemotePageContent( + settings: _settingsValue(page.settings), + revision: page.contentRevision.toInt(), + createdAt: _dateTime(page.contentCreatedAt), + updatedAt: _dateTime(page.contentUpdatedAt), + ), + ); + }).toList(growable: false); + final elements = result.elements.map(_element).toList(growable: false); + final lineups = result.lineups.map(_lineup).toList(growable: false); + final assets = result.assets.map(_imageAsset).toList(growable: false); + return RemoteFullStrategySnapshot( + header: _strategyHeader(result.header), + pages: pages, + elementsByPage: RemoteFullStrategySnapshot.groupElementsByPage(elements), + lineupsByPage: RemoteFullStrategySnapshot.groupLineupsByPage(lineups), + assetsById: {for (final asset in assets) asset.publicId: asset}, + ); +} + +RemoteStrategyHeader _strategyHeader(StrategiesGetHeaderResult header) { + return RemoteStrategyHeader( + publicId: header.publicId, + name: header.name, + mapData: header.mapData, + revision: header.revision.toInt(), + createdAt: _dateTime(header.createdAt), + updatedAt: _dateTime(header.updatedAt), + themeProfileId: header.themeProfileId, + themeOverridePalette: _themePaletteValue(header.themeOverridePalette), + role: header.role.wireName, + ); +} + +RemotePage _page(PageGetSnapshotResultPage page) { + return RemotePage( + publicId: page.publicId, + strategyPublicId: page.strategyPublicId, + name: page.name, + isAutoNamed: _optionalValue(page.isAutoNamed), + sortIndex: page.sortIndex.toInt(), + isAttack: page.isAttack, + revision: page.revision.toInt(), + createdAt: _dateTime(page.createdAt), + updatedAt: _dateTime(page.updatedAt), + ); +} + +RemotePageContent _pageContent(PageGetSnapshotResultContent content) { + return RemotePageContent( + settings: _settingsValue(content.settings), + revision: content.revision.toInt(), + createdAt: _dateTime(content.createdAt), + updatedAt: _dateTime(content.updatedAt), + ); +} + +RemoteElement _element(ElementsListForPageResultItem element) { + return RemoteElement( + publicId: element.publicId, + strategyPublicId: element.strategyPublicId, + pagePublicId: element.pagePublicId, + elementType: element.elementType.wireName, + payload: element.payload, + sortIndex: element.sortIndex.toInt(), + revision: element.revision.toInt(), + deleted: element.deleted, + ); +} + +RemoteLineup _lineup(LineupsListForPageResultItem lineup) { + return RemoteLineup( + publicId: lineup.publicId, + strategyPublicId: lineup.strategyPublicId, + pagePublicId: lineup.pagePublicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex.toInt(), + revision: lineup.revision.toInt(), + deleted: lineup.deleted, + ); +} + +RemoteImageAsset _imageAsset(ImagesListForStrategyResultItem asset) { + return RemoteImageAsset( + publicId: asset.publicId, + provider: asset.provider.wireName, + uploadStatus: asset.uploadStatus.wireName, + fileExtension: asset.fileExtension, + mimeType: asset.mimeType, + width: asset.width?.toInt(), + height: asset.height?.toInt(), + byteSize: asset.byteSize?.toInt(), + uploadedAt: asset.uploadedAt == null ? null : _dateTime(asset.uploadedAt!), + url: asset.url, + legacyStoragePath: asset.legacyStoragePath, + ); +} + +OpAck _opAck(OpsApplyBatchResultResultsItem result) { + return switch (result) { + OpsApplyBatchResultResultsItemApplied( + :final opId, + :final appliedRevision + ) => + AppliedOpAck(opId: opId, revision: appliedRevision.toInt()), + OpsApplyBatchResultResultsItemNoop(:final opId, :final currentRevision) => + NoopOpAck( + opId: opId, + currentRevision: + currentRevision.isPresent ? currentRevision.value.toInt() : null, + ), + OpsApplyBatchResultResultsItemRejected( + :final opId, + :final reason, + :final current, + ) => + RejectedOpAck( + opId: opId, + rejectionReason: OpRejectionReason.fromWireName(reason.wireName), + current: current.isPresent ? _currentSnapshot(current.value) : null, + ), + OpsApplyBatchResultResultsItemFailed( + :final opId, + :final code, + :final rawCode, + :final message, + ) => + FailedOpAck( + opId: opId, + code: code, + rawCode: rawCode, + message: message, + ), + }; +} + +CurrentOpSnapshot _currentSnapshot( + OpsApplyBatchResultResultsItemRejectedCurrent current, +) { + return switch (current) { + OpsApplyBatchResultResultsItemRejectedCurrentStrategy( + :final revision, + :final value, + ) => + StrategyCurrentSnapshot( + revision: revision.toInt(), + value: { + 'name': value.name, + 'mapData': value.mapData, + 'themeProfileId': value.themeProfileId, + 'themeOverridePalette': _themePaletteValue( + value.themeOverridePalette, + ), + }, + ), + OpsApplyBatchResultResultsItemRejectedCurrentPage( + :final revision, + :final value, + ) => + PageCurrentSnapshot( + revision: revision.toInt(), + value: { + 'name': value.name, + if (value.isAutoNamed.isPresent) + 'isAutoNamed': value.isAutoNamed.value, + 'sortIndex': value.sortIndex, + 'isAttack': value.isAttack, + }, + ), + OpsApplyBatchResultResultsItemRejectedCurrentPageContent( + :final revision, + :final value, + ) => + PageContentCurrentSnapshot( + revision: revision.toInt(), + value: {'settings': _settingsValue(value.settings)}, + ), + OpsApplyBatchResultResultsItemRejectedCurrentElement( + :final revision, + :final value, + ) => + ElementCurrentSnapshot(revision: revision.toInt(), value: value), + OpsApplyBatchResultResultsItemRejectedCurrentLineup( + :final revision, + :final value, + ) => + LineupCurrentSnapshot(revision: revision.toInt(), value: value), + }; +} + +CloudPayload? _themePaletteValue( + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette? palette, +) { + return palette == null ? null : _object(palette.encode('theme')); +} + +CloudPayload? _settingsValue( + OpsApplyBatchArgsOpsItemPageAddPayloadSettings? settings, +) { + return settings == null ? null : _object(settings.encode('settings')); +} + +Map _object(ConvexObject value) { + return Map.from(value.toDart()); +} + +DateTime _dateTime(double milliseconds) => + DateTime.fromMillisecondsSinceEpoch(milliseconds.toInt()); + +ConvexOptional _optional(T? value) => value == null + ? const ConvexOptional.absent() + : ConvexOptional.present(value); + +T? _optionalValue(ConvexOptional value) => + value.isPresent ? value.value : null; + +ConvexOptional _optionalNumber(num? value) => value == null + ? const ConvexOptional.absent() + : ConvexOptional.present(value.toDouble()); + +ConvexOptional _presentWhenTrue(bool value) => + value ? const ConvexOptional.present(true) : const ConvexOptional.absent(); + +ConvexOptional + _themePalette(Map? value) { + if (value == null) return const ConvexOptional.absent(); + return ConvexOptional.present( + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + ConvexValue.fromDart(value), + 'themeOverridePalette', + ), + ); +} + +ConvexOptional _pageSettings( + Map? value, +) { + if (value == null) return const ConvexOptional.absent(); + return ConvexOptional.present( + OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + ConvexValue.fromDart(value), + 'settings', + ), + ); +} + +FoldersListTreeArgsScope _folderScope(String value) => switch (value) { + 'all' => FoldersListTreeArgsScope.all, + 'owned' => FoldersListTreeArgsScope.owned, + 'shared' => FoldersListTreeArgsScope.shared, + _ => + throw ArgumentError.value(value, 'scope', 'Unsupported folder scope'), + }; + +ImagesCompleteUploadArgsProvider _imageProvider(String value) => + switch (value) { + 'convex' => ImagesCompleteUploadArgsProvider.convex, + 'r2' => ImagesCompleteUploadArgsProvider.r2, + _ => throw ArgumentError.value( + value, + 'provider', + 'Unsupported image provider', + ), + }; + +SharesCreateArgsTargetType _shareTargetType(String value) => switch (value) { + 'folder' => SharesCreateArgsTargetType.folder, + 'strategy' => SharesCreateArgsTargetType.strategy, + _ => throw ArgumentError.value( + value, + 'targetType', + 'Unsupported share target type', + ), + }; + +InvitesCreateArgsRole _shareRole(String value) => switch (value) { + 'editor' => InvitesCreateArgsRole.editor, + 'viewer' => InvitesCreateArgsRole.viewer, + _ => throw ArgumentError.value(value, 'role', 'Unsupported share role'), + }; diff --git a/lib/collab/durable_cloud_media_outbox.dart b/lib/collab/durable_cloud_media_outbox.dart new file mode 100644 index 00000000..3474efef --- /dev/null +++ b/lib/collab/durable_cloud_media_outbox.dart @@ -0,0 +1,327 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +const durableCloudMediaOutboxRecordVersion = 2; +const durableCloudMediaOutboxVersionKey = '__media_outbox_record_version__'; + +Future prepareDurableCloudMediaOutbox() async { + final box = Hive.box(HiveBoxNames.cloudMediaOutboxBox); + final version = box.get(durableCloudMediaOutboxVersionKey); + if (version == durableCloudMediaOutboxRecordVersion) { + return; + } + if (version != null && version != 1) { + throw StateError( + 'The media outbox has an unsupported record version. ' + 'Refusing to discard pending media work.', + ); + } + // Prerelease v1 records had no owning account. Keep them byte-for-byte + // for recovery, and let load() report them as unreadable saved work. + // Never assign them to whichever account happens to sign in next. + await box.put( + durableCloudMediaOutboxVersionKey, + durableCloudMediaOutboxRecordVersion, + ); +} + +class DurableCloudMediaOutboxLoadIssue { + const DurableCloudMediaOutboxLoadIssue({ + required this.storageKey, + required this.error, + }); + + final String storageKey; + final String error; +} + +class DurableCloudMediaOutboxLoadResult { + const DurableCloudMediaOutboxLoadResult({ + required this.jobs, + required this.issues, + }); + + final List jobs; + final List issues; +} + +abstract class DurableCloudMediaOutboxStore { + DurableCloudMediaOutboxLoadResult load(); + Future put(CloudMediaUploadJob job); + Future putAll(Iterable jobs); + Future remove(CloudMediaUploadJob job); +} + +String durableCloudMediaOutboxStorageKey(CloudMediaUploadJob job) { + return durableCloudMediaOutboxStorageKeyFor( + accountId: job.accountId, + jobId: job.jobId, + ); +} + +String durableCloudMediaOutboxStorageKeyFor({ + required String accountId, + required String jobId, +}) { + return '${Uri.encodeComponent(accountId)}|${Uri.encodeComponent(jobId)}'; +} + +class HiveDurableCloudMediaOutboxStore implements DurableCloudMediaOutboxStore { + Box get _box => Hive.box(HiveBoxNames.cloudMediaOutboxBox); + + @override + DurableCloudMediaOutboxLoadResult load() { + final jobs = []; + final issues = []; + for (final key in _box.keys) { + final storageKey = key.toString(); + if (storageKey == durableCloudMediaOutboxVersionKey) { + continue; + } + try { + final raw = _box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + final job = _jobFromJson(json); + if (durableCloudMediaOutboxStorageKey(job) != storageKey) { + throw const FormatException( + 'Media outbox storage key does not match job', + ); + } + jobs.add(job); + } catch (error) { + issues.add( + DurableCloudMediaOutboxLoadIssue( + storageKey: storageKey, + error: error.toString(), + ), + ); + } + } + return DurableCloudMediaOutboxLoadResult(jobs: jobs, issues: issues); + } + + @override + Future put(CloudMediaUploadJob job) { + final jsonSafe = Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ); + return _box.put(durableCloudMediaOutboxStorageKey(job), jsonSafe); + } + + @override + Future putAll(Iterable jobs) { + final values = >{ + for (final job in jobs) + durableCloudMediaOutboxStorageKey(job): Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ), + }; + return _box.putAll(values); + } + + @override + Future remove(CloudMediaUploadJob job) => + _box.delete(durableCloudMediaOutboxStorageKey(job)); +} + +class MemoryDurableCloudMediaOutboxStore + implements DurableCloudMediaOutboxStore { + MemoryDurableCloudMediaOutboxStore([Map? initialValues]) + : values = Map.from(initialValues ?? const {}); + + final Map values; + + @override + DurableCloudMediaOutboxLoadResult load() { + final jobs = []; + final issues = []; + for (final entry in values.entries) { + try { + final value = entry.value; + final json = value is Map + ? value + : Map.from(value as Map); + final job = _jobFromJson(json); + if (durableCloudMediaOutboxStorageKey(job) != entry.key) { + throw const FormatException( + 'Media outbox storage key does not match job', + ); + } + jobs.add(job); + } catch (error) { + issues.add( + DurableCloudMediaOutboxLoadIssue( + storageKey: entry.key, + error: error.toString(), + ), + ); + } + } + return DurableCloudMediaOutboxLoadResult(jobs: jobs, issues: issues); + } + + @override + Future put(CloudMediaUploadJob job) async { + values[durableCloudMediaOutboxStorageKey(job)] = Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ); + } + + @override + Future putAll(Iterable jobs) async { + final encoded = >{ + for (final job in jobs) + durableCloudMediaOutboxStorageKey(job): Map.from( + jsonDecode(jsonEncode(_jobToJson(job))) as Map, + ), + }; + values.addAll(encoded); + } + + @override + Future remove(CloudMediaUploadJob job) async { + values.remove(durableCloudMediaOutboxStorageKey(job)); + } +} + +/// The media outbox in a run where cloud sync did not start. It reads +/// nothing and writes nothing, so the box file stays exactly as the last run +/// left it. Its one load issue keeps pending image bytes from being pruned: +/// only a clean load proves no job needs them. +class UnavailableDurableCloudMediaOutboxStore + implements DurableCloudMediaOutboxStore { + const UnavailableDurableCloudMediaOutboxStore(this.reason); + + final String reason; + + @override + DurableCloudMediaOutboxLoadResult load() => DurableCloudMediaOutboxLoadResult( + jobs: const [], + issues: [ + DurableCloudMediaOutboxLoadIssue( + storageKey: HiveBoxNames.cloudMediaOutboxBox, + error: reason, + ), + ], + ); + + @override + Future put(CloudMediaUploadJob job) => + Future.error(CloudUnavailableException(reason)); + + @override + Future putAll(Iterable jobs) => + Future.error(CloudUnavailableException(reason)); + + @override + Future remove(CloudMediaUploadJob job) => + Future.error(CloudUnavailableException(reason)); +} + +final durableCloudMediaOutboxStoreProvider = + Provider( + (ref) { + final unavailableReason = ref.watch(cloudStartupProvider).unavailableReason; + return unavailableReason == null + ? HiveDurableCloudMediaOutboxStore() + : UnavailableDurableCloudMediaOutboxStore(unavailableReason); + }, +); + +Map _jobToJson(CloudMediaUploadJob job) { + final accountId = job.accountId; + if (accountId.isEmpty) { + throw StateError('New media outbox records require an owning account.'); + } + return { + 'outboxVersion': durableCloudMediaOutboxRecordVersion, + 'jobId': job.jobId, + 'accountId': accountId, + 'strategyPublicId': job.strategyPublicId, + 'assetPublicId': job.assetPublicId, + 'fileExtension': job.fileExtension, + 'mimeType': job.mimeType, + if (job.width != null) 'width': job.width, + if (job.height != null) 'height': job.height, + if (job.byteSize != null) 'byteSize': job.byteSize, + if (job.provider != null) 'provider': job.provider, + if (job.uploadId != null) 'uploadId': job.uploadId, + if (job.objectKey != null) 'objectKey': job.objectKey, + if (job.storageId != null) 'storageId': job.storageId, + if (job.etag != null) 'etag': job.etag, + if (job.uploadUrlExpiresAt != null) + 'uploadUrlExpiresAt': job.uploadUrlExpiresAt!.toUtc().toIso8601String(), + 'state': job.state.name, + 'referenceDurable': job.referenceDurable, + 'attempts': job.attempts, + if (job.lastError != null) 'lastError': job.lastError, + 'updatedAt': job.updatedAt.toUtc().toIso8601String(), + }; +} + +CloudMediaUploadJob _jobFromJson(Map json) { + final version = (json['outboxVersion'] as num?)?.toInt(); + if (version != durableCloudMediaOutboxRecordVersion) { + throw FormatException('Unsupported media outbox record version: $version'); + } + return CloudMediaUploadJob( + jobId: _nonEmptyString(json['jobId'], field: 'jobId'), + accountId: _nonEmptyString(json['accountId'], field: 'accountId'), + strategyPublicId: _nonEmptyString( + json['strategyPublicId'], + field: 'strategyPublicId', + ), + assetPublicId: _nonEmptyString( + json['assetPublicId'], + field: 'assetPublicId', + ), + fileExtension: json['fileExtension'] as String? ?? '', + mimeType: _nonEmptyString(json['mimeType'], field: 'mimeType'), + width: (json['width'] as num?)?.toInt(), + height: (json['height'] as num?)?.toInt(), + byteSize: (json['byteSize'] as num?)?.toInt(), + provider: json['provider'] as String?, + uploadId: json['uploadId'] as String?, + objectKey: json['objectKey'] as String?, + storageId: json['storageId'] as String?, + etag: json['etag'] as String?, + uploadUrlExpiresAt: _optionalDate(json['uploadUrlExpiresAt']), + state: CloudMediaJobState.values.byName( + _nonEmptyString(json['state'], field: 'state'), + ), + referenceDurable: json['referenceDurable'] as bool? ?? true, + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + lastError: json['lastError'] as String?, + updatedAt: _requiredDate(json['updatedAt'], field: 'updatedAt'), + ); +} + +String _nonEmptyString(Object? value, {required String field}) { + if (value is String && value.isNotEmpty) { + return value; + } + throw FormatException('Media outbox $field must be a non-empty string'); +} + +DateTime _requiredDate(Object? value, {required String field}) { + final parsed = _optionalDate(value); + if (parsed != null) { + return parsed; + } + throw FormatException('Media outbox $field must be an ISO-8601 date'); +} + +DateTime? _optionalDate(Object? value) { + if (value is! String) { + return null; + } + return DateTime.tryParse(value)?.toLocal(); +} diff --git a/lib/collab/durable_strategy_outbox.dart b/lib/collab/durable_strategy_outbox.dart new file mode 100644 index 00000000..b0c25a91 --- /dev/null +++ b/lib/collab/durable_strategy_outbox.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; + +const durableOutboxRecordVersion = 2; +const _legacyDurableOutboxRecordVersion = 1; +const durableOutboxVersionKey = '__outbox_record_version__'; + +Future prepareDurableStrategyOutbox() async { + final box = Hive.box(HiveBoxNames.strategyOutboxBox); + if (box.get(durableOutboxVersionKey) == durableOutboxRecordVersion) return; + + final storageKeys = box.keys + .where((key) => key.toString() != durableOutboxVersionKey) + .toList(growable: false); + for (final key in storageKeys) { + final storageKey = key.toString(); + DurableOutboxRecord record; + try { + final raw = box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != storageKey) continue; + } catch (_) { + // Keep unreadable records byte-for-byte. The store loader quarantines + // them as load issues instead of risking deletion of saved cloud work. + continue; + } + await box.put(key, record.toJson()); + } + await box.put(durableOutboxVersionKey, durableOutboxRecordVersion); +} + +enum DurableOutboxStatus { queued, inFlight, paused, attention } + +class DurableOutboxRecord { + const DurableOutboxRecord({ + required this.accountId, + required this.strategyPublicId, + required this.entityKey, + required this.pending, + required this.status, + required this.createdAt, + required this.updatedAt, + this.successorPending, + this.lastError, + this.latestServerRevision, + }); + + final String accountId; + final String strategyPublicId; + final EntitySyncKey entityKey; + final PendingOp pending; + final DurableOutboxStatus status; + final DateTime createdAt; + final DateTime updatedAt; + final PendingOp? successorPending; + final String? lastError; + final int? latestServerRevision; + + String get storageKey => createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: entityKey, + ); + + static String createStorageKey({ + required String accountId, + required String strategyPublicId, + required EntitySyncKey entityKey, + }) { + return '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|$entityKey'; + } + + DurableOutboxRecord copyWith({ + PendingOp? pending, + DurableOutboxStatus? status, + DateTime? updatedAt, + PendingOp? successorPending, + bool clearSuccessorPending = false, + String? lastError, + bool clearError = false, + int? latestServerRevision, + bool clearLatestServerRevision = false, + }) { + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: entityKey, + pending: pending ?? this.pending, + status: status ?? this.status, + createdAt: createdAt, + updatedAt: updatedAt ?? this.updatedAt, + successorPending: clearSuccessorPending + ? null + : (successorPending ?? this.successorPending), + lastError: clearError ? null : (lastError ?? this.lastError), + latestServerRevision: clearLatestServerRevision + ? null + : (latestServerRevision ?? this.latestServerRevision), + ); + } + + Map toJson() => { + 'outboxVersion': durableOutboxRecordVersion, + 'accountId': accountId, + 'strategyPublicId': strategyPublicId, + 'entityKey': entityKey.toString(), + 'clientId': pending.clientId, + 'opId': pending.op.opId, + 'op': pending.op.toConvexJson(), + 'attempts': pending.attempts, + if (pending.lastAttemptAt != null) + 'lastAttemptAt': pending.lastAttemptAt!.toUtc().toIso8601String(), + if (successorPending != null) + 'successorPending': _pendingToJson(successorPending!), + 'status': status.name, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'updatedAt': updatedAt.toUtc().toIso8601String(), + if (lastError != null) 'lastError': lastError, + if (latestServerRevision != null) + 'latestServerRevision': latestServerRevision, + }; + + factory DurableOutboxRecord.fromJson(Map json) { + final version = (json['outboxVersion'] as num?)?.toInt(); + if (version != _legacyDurableOutboxRecordVersion && + version != durableOutboxRecordVersion) { + throw FormatException('Unsupported outbox record version: $version'); + } + final opJson = _object(json['op'], field: 'op'); + final op = StrategyOp.fromJson(opJson); + if (json['opId'] != op.opId) { + throw const FormatException('Outbox opId does not match serialized op'); + } + final entityKey = EntitySyncKey.forStrategyOp(op); + if (entityKey == null || entityKey.toString() != json['entityKey']) { + throw const FormatException('Outbox entity key does not match op'); + } + final successor = json['successorPending'] == null + ? null + : _pendingFromJson( + _object(json['successorPending'], field: 'successorPending'), + ); + if (successor != null && + EntitySyncKey.forStrategyOp(successor.op) != entityKey) { + throw const FormatException( + 'Outbox successor entity key does not match op', + ); + } + return DurableOutboxRecord( + accountId: _nonEmptyString(json['accountId'], field: 'accountId'), + strategyPublicId: + _nonEmptyString(json['strategyPublicId'], field: 'strategyPublicId'), + entityKey: entityKey, + pending: PendingOp( + op: op, + clientId: _nonEmptyString(json['clientId'], field: 'clientId'), + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + lastAttemptAt: _optionalDate(json['lastAttemptAt']), + ), + successorPending: successor, + status: DurableOutboxStatus.values.byName( + _nonEmptyString(json['status'], field: 'status'), + ), + createdAt: _requiredDate(json['createdAt'], field: 'createdAt'), + updatedAt: _requiredDate(json['updatedAt'], field: 'updatedAt'), + lastError: json['lastError'] as String?, + latestServerRevision: (json['latestServerRevision'] as num?)?.toInt(), + ); + } + + static Map _pendingToJson(PendingOp value) => + { + 'clientId': value.clientId, + 'opId': value.op.opId, + 'op': value.op.toConvexJson(), + 'attempts': value.attempts, + if (value.lastAttemptAt != null) + 'lastAttemptAt': value.lastAttemptAt!.toUtc().toIso8601String(), + }; + + static PendingOp _pendingFromJson(Map json) { + final op = StrategyOp.fromJson(_object(json['op'], field: 'successor op')); + if (json['opId'] != op.opId) { + throw const FormatException( + 'Outbox successor opId does not match serialized op', + ); + } + return PendingOp( + op: op, + clientId: _nonEmptyString(json['clientId'], field: 'successor clientId'), + attempts: (json['attempts'] as num?)?.toInt() ?? 0, + lastAttemptAt: _optionalDate(json['lastAttemptAt']), + ); + } + + static Map _object(Object? value, {required String field}) { + if (value is Map) return value; + if (value is Map) return Map.from(value); + throw FormatException('Outbox $field must be an object'); + } + + static String _nonEmptyString(Object? value, {required String field}) { + if (value is String && value.isNotEmpty) return value; + throw FormatException('Outbox $field must be a non-empty string'); + } + + static DateTime _requiredDate(Object? value, {required String field}) { + final parsed = _optionalDate(value); + if (parsed != null) return parsed; + throw FormatException('Outbox $field must be an ISO-8601 date'); + } + + static DateTime? _optionalDate(Object? value) { + if (value is! String) return null; + return DateTime.tryParse(value)?.toLocal(); + } +} + +class DurableOutboxLoadIssue { + const DurableOutboxLoadIssue({required this.storageKey, required this.error}); + + final String storageKey; + final String error; +} + +class DurableOutboxLoadResult { + const DurableOutboxLoadResult({required this.records, required this.issues}); + + final List records; + final List issues; +} + +abstract class DurableStrategyOutboxStore { + DurableOutboxLoadResult load(); + Future put(DurableOutboxRecord record); + Future remove(String storageKey); +} + +class HiveDurableStrategyOutboxStore implements DurableStrategyOutboxStore { + Box get _box => Hive.box(HiveBoxNames.strategyOutboxBox); + + @override + DurableOutboxLoadResult load() { + final records = []; + final issues = []; + for (final key in _box.keys) { + final storageKey = key.toString(); + if (storageKey == durableOutboxVersionKey) continue; + try { + final raw = _box.get(key); + final decoded = raw is String ? jsonDecode(raw) : raw; + final json = decoded is Map + ? decoded + : Map.from(decoded as Map); + final record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != storageKey) { + throw const FormatException( + 'Outbox storage key does not match record'); + } + records.add(record); + } catch (error) { + issues.add(DurableOutboxLoadIssue( + storageKey: storageKey, + error: error.toString(), + )); + } + } + return DurableOutboxLoadResult(records: records, issues: issues); + } + + @override + Future put(DurableOutboxRecord record) { + final jsonSafe = Map.from( + jsonDecode(jsonEncode(record.toJson())) as Map, + ); + return _box.put(record.storageKey, jsonSafe); + } + + @override + Future remove(String storageKey) => _box.delete(storageKey); +} + +class MemoryDurableStrategyOutboxStore implements DurableStrategyOutboxStore { + MemoryDurableStrategyOutboxStore([ + Map? initialValues, + ]) : values = Map.from(initialValues ?? const {}); + + final Map values; + + @override + DurableOutboxLoadResult load() { + final records = []; + final issues = []; + for (final entry in values.entries) { + try { + final value = entry.value; + final json = value is Map + ? value + : Map.from(value as Map); + final record = DurableOutboxRecord.fromJson(json); + if (record.storageKey != entry.key) { + throw const FormatException( + 'Outbox storage key does not match record'); + } + records.add(record); + } catch (error) { + issues.add(DurableOutboxLoadIssue( + storageKey: entry.key, + error: error.toString(), + )); + } + } + return DurableOutboxLoadResult(records: records, issues: issues); + } + + @override + Future put(DurableOutboxRecord record) async { + values[record.storageKey] = Map.from( + jsonDecode(jsonEncode(record.toJson())) as Map, + ); + } + + @override + Future remove(String storageKey) async { + values.remove(storageKey); + } +} + +/// The outbox in a run where cloud sync did not start. It reads nothing and +/// writes nothing, so the box file stays exactly as the last run left it. +/// Its one load issue keeps everything that waits on a clean load waiting. +class UnavailableDurableStrategyOutboxStore + implements DurableStrategyOutboxStore { + const UnavailableDurableStrategyOutboxStore(this.reason); + + final String reason; + + @override + DurableOutboxLoadResult load() => DurableOutboxLoadResult( + records: const [], + issues: [ + DurableOutboxLoadIssue( + storageKey: HiveBoxNames.strategyOutboxBox, + error: reason, + ), + ], + ); + + @override + Future put(DurableOutboxRecord record) => + Future.error(CloudUnavailableException(reason)); + + @override + Future remove(String storageKey) => + Future.error(CloudUnavailableException(reason)); +} + +final durableStrategyOutboxStoreProvider = Provider( + (ref) { + final unavailableReason = ref.watch(cloudStartupProvider).unavailableReason; + return unavailableReason == null + ? HiveDurableStrategyOutboxStore() + : UnavailableDurableStrategyOutboxStore(unavailableReason); + }, +); diff --git a/lib/collab/generated/convex_error_codes.dart b/lib/collab/generated/convex_error_codes.dart new file mode 100644 index 00000000..fb8b0bbb --- /dev/null +++ b/lib/collab/generated/convex_error_codes.dart @@ -0,0 +1,83 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from convex/function_spec.json by tool/icarus_convex_codegen. +// ignore_for_file: prefer_const_constructors, unused_element, unused_import + +import '../transport/convex_transport.dart'; + +enum ConvexErrorCode { + clientUpgradeRequired('CLIENT_UPGRADE_REQUIRED'), + conflict('CONFLICT'), + elementStrategyMismatch('ELEMENT_STRATEGY_MISMATCH'), + elementTypePayloadKindMismatch('ELEMENT_TYPE_PAYLOAD_KIND_MISMATCH'), + forbidden('FORBIDDEN'), + internalError('INTERNAL_ERROR'), + invalidElementPayloadData('INVALID_ELEMENT_PAYLOAD_DATA'), + invalidElementPayloadKind('INVALID_ELEMENT_PAYLOAD_KIND'), + invalidElementPayloadVersion('INVALID_ELEMENT_PAYLOAD_VERSION'), + invalidLineupPayloadData('INVALID_LINEUP_PAYLOAD_DATA'), + invalidLineupPayloadKind('INVALID_LINEUP_PAYLOAD_KIND'), + invalidLineupPayloadVersion('INVALID_LINEUP_PAYLOAD_VERSION'), + invalidOp('INVALID_OP'), + invalidPageContentCount('INVALID_PAGE_CONTENT_COUNT'), + invalidPayload('INVALID_PAYLOAD'), + inviteExpired('INVITE_EXPIRED'), + inviteRevoked('INVITE_REVOKED'), + lineupPageMismatch('LINEUP_PAGE_MISMATCH'), + lineupStrategyMismatch('LINEUP_STRATEGY_MISMATCH'), + missingAddElementArgs('MISSING_ADD_ELEMENT_ARGS'), + missingAddLineupArgs('MISSING_ADD_LINEUP_ARGS'), + missingElementPayload('MISSING_ELEMENT_PAYLOAD'), + missingEntityPublicId('MISSING_ENTITY_PUBLIC_ID'), + missingLineupPayload('MISSING_LINEUP_PAYLOAD'), + missingPageId('MISSING_PAGE_ID'), + missingPagePublicId('MISSING_PAGE_PUBLIC_ID'), + notFound('NOT_FOUND'), + pageDeleted('PAGE_DELETED'), + pageDescriptorRequiresPageOp('PAGE_DESCRIPTOR_REQUIRES_PAGE_OP'), + pageSettingsRequirePageContent('PAGE_SETTINGS_REQUIRE_PAGE_CONTENT'), + pageStrategyMismatch('PAGE_STRATEGY_MISMATCH'), + r2ObjectKeyMismatch('R2_OBJECT_KEY_MISMATCH'), + shareLinkRevoked('SHARE_LINK_REVOKED'), + strategyTooLargeToDuplicate('STRATEGY_TOO_LARGE_TO_DUPLICATE'), + unauthenticated('UNAUTHENTICATED'), + unsupportedOp('UNSUPPORTED_OP'), + uploadIntentNotFound('UPLOAD_INTENT_NOT_FOUND'), + unknown('UNKNOWN'); + + const ConvexErrorCode(this.wireName); + final String wireName; + + static ConvexErrorCode fromWireName(String rawCode) { + for (final code in values) { + if (code != unknown && code.wireName == rawCode) return code; + } + return unknown; + } +} + +final class ConvexFunctionException implements Exception { + const ConvexFunctionException({ + required this.code, + required this.rawCode, + required this.message, + this.data, + }); + + factory ConvexFunctionException.fromTransport(ConvexTransportError error) => + ConvexFunctionException( + code: ConvexErrorCode.fromWireName(error.rawCode), + rawCode: error.rawCode, + message: error.message, + data: error.data, + ); + + final ConvexErrorCode code; + final String rawCode; + final String message; + final ConvexValue? data; + + @override + String toString() => + 'ConvexFunctionException(' + '$rawCode, $message)'; +} diff --git a/lib/collab/generated/convex_models.dart b/lib/collab/generated/convex_models.dart new file mode 100644 index 00000000..e5c8d9a4 --- /dev/null +++ b/lib/collab/generated/convex_models.dart @@ -0,0 +1,5989 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from convex/function_spec.json by tool/icarus_convex_codegen. +// ignore_for_file: prefer_const_constructors, unused_element, unused_import + +import 'dart:typed_data'; + +import '../convex_payload_codecs.dart'; +import '../transport/convex_transport.dart'; + +final class ConvexOptional { + const ConvexOptional.absent() : isPresent = false, _value = null; + const ConvexOptional.present(T value) : isPresent = true, _value = value; + + final bool isPresent; + final T? _value; + + T get value { + if (!isPresent) throw StateError('Optional value is absent'); + return _value as T; + } +} + +final class ConvexDecodingException extends FormatException { + ConvexDecodingException(this.path, String message) : super('$path: $message'); + final String path; +} + +final class ConvexEncodingException extends FormatException { + ConvexEncodingException(this.path, String message) : super('$path: $message'); + final String path; +} + +Never _missing(String path, String field) => + throw ConvexDecodingException('$path.$field', 'missing required field'); + +String _fieldPath(String path, String field) => '$path.$field'; + +String _indexPath(String path, int index) => '$path[$index]'; + +void _checkObjectFields(ConvexObject object, String path, Set allowed) { + for (final field in object.value.keys) { + if (!allowed.contains(field)) { + throw ConvexDecodingException('$path.$field', 'unexpected field'); + } + } +} + +Null _decodeNull(ConvexValue value, String path) { + if (value is ConvexNull) return null; + throw ConvexDecodingException(path, 'expected null'); +} + +bool _decodeBoolean(ConvexValue value, String path) { + if (value case ConvexBoolean(:final value)) return value; + throw ConvexDecodingException(path, 'expected boolean'); +} + +double _decodeNumber(ConvexValue value, String path) { + if (value case ConvexFloat(:final value)) return value; + if (value case ConvexInteger(:final value)) return value.toDouble(); + throw ConvexDecodingException(path, 'expected number'); +} + +ConvexValue _encodeNumber(double value, String path) { + return ConvexFloat(value); +} + +BigInt _decodeBigInt(ConvexValue value, String path) { + if (value case ConvexBigInt(:final value)) return value; + throw ConvexDecodingException(path, 'expected bigint'); +} + +String _decodeString(ConvexValue value, String path) { + if (value case ConvexString(:final value)) return value; + throw ConvexDecodingException(path, 'expected string'); +} + +Uint8List _decodeBytes(ConvexValue value, String path) { + if (value case ConvexBytes(:final value)) return Uint8List.fromList(value); + throw ConvexDecodingException(path, 'expected bytes'); +} + +ConvexArray _decodeArray(ConvexValue value, String path) { + if (value is ConvexArray) return value; + throw ConvexDecodingException(path, 'expected array'); +} + +ConvexObject _decodeObject(ConvexValue value, String path) { + if (value is ConvexObject) return value; + throw ConvexDecodingException(path, 'expected object'); +} + +T _expectLiteral(T value, T expected, String path) { + if (value == expected) return value; + throw ConvexDecodingException(path, 'expected literal $expected'); +} + +T _decodePayload(T Function() decode, String path) { + try { + return decode(); + } on FormatException catch (error) { + throw ConvexDecodingException(path, error.message); + } +} + +ConvexValue _encodePayload(ConvexValue Function() encode, String path) { + try { + return encode(); + } on FormatException catch (error) { + throw ConvexEncodingException(path, error.message); + } +} + +ConvexValue _decodeRaw( + ConvexValue value, + String path, + bool Function(ConvexValue) accepts, +) { + if (accepts(value)) return value; + throw ConvexDecodingException(path, 'value does not satisfy closed union'); +} + +enum ElementsListForPageResultItemElementType { + ability('ability'), + agent('agent'), + drawing('drawing'), + image('image'), + text('text'), + utility('utility'); + + const ElementsListForPageResultItemElementType(this.wireName); + final String wireName; + + static ElementsListForPageResultItemElementType fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown ElementsListForPageResultItemElementType $wireName', + ); + } +} + +enum FoldersListTreeArgsScope { + all('all'), + owned('owned'), + shared('shared'); + + const FoldersListTreeArgsScope(this.wireName); + final String wireName; + + static FoldersListTreeArgsScope fromWireName(String wireName, String path) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown FoldersListTreeArgsScope $wireName', + ); + } +} + +enum FoldersListTreeResultItemRole { + editor('editor'), + owner('owner'), + viewer('viewer'); + + const FoldersListTreeResultItemRole(this.wireName); + final String wireName; + + static FoldersListTreeResultItemRole fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown FoldersListTreeResultItemRole $wireName', + ); + } +} + +enum HealthPingResult { + ok('ok'); + + const HealthPingResult(this.wireName); + final String wireName; + + static HealthPingResult fromWireName(String wireName, String path) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException(path, 'unknown HealthPingResult $wireName'); + } +} + +enum ImagesCompleteUploadArgsProvider { + convex('convex'), + r2('r2'); + + const ImagesCompleteUploadArgsProvider(this.wireName); + final String wireName; + + static ImagesCompleteUploadArgsProvider fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown ImagesCompleteUploadArgsProvider $wireName', + ); + } +} + +enum ImagesGenerateUploadUrlResultProvider { + r2('r2'); + + const ImagesGenerateUploadUrlResultProvider(this.wireName); + final String wireName; + + static ImagesGenerateUploadUrlResultProvider fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown ImagesGenerateUploadUrlResultProvider $wireName', + ); + } +} + +enum ImagesListForStrategyResultItemUploadStatus { + active('active'), + deleted('deleted'), + failed('failed'), + pending('pending'); + + const ImagesListForStrategyResultItemUploadStatus(this.wireName); + final String wireName; + + static ImagesListForStrategyResultItemUploadStatus fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown ImagesListForStrategyResultItemUploadStatus $wireName', + ); + } +} + +enum InvitesCreateArgsRole { + editor('editor'), + viewer('viewer'); + + const InvitesCreateArgsRole(this.wireName); + final String wireName; + + static InvitesCreateArgsRole fromWireName(String wireName, String path) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown InvitesCreateArgsRole $wireName', + ); + } +} + +enum OpsApplyBatchResultResultsItemRejectedReason { + alreadyExists('already_exists'), + deleted('deleted'), + elementStrategyMismatch('element_strategy_mismatch'), + lineupStrategyMismatch('lineup_strategy_mismatch'), + missingExpectedRevision('missing_expected_revision'), + notFound('not_found'), + pageStrategyMismatch('page_strategy_mismatch'), + revisionMismatch('revision_mismatch'); + + const OpsApplyBatchResultResultsItemRejectedReason(this.wireName); + final String wireName; + + static OpsApplyBatchResultResultsItemRejectedReason fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown OpsApplyBatchResultResultsItemRejectedReason $wireName', + ); + } +} + +enum SharesCreateArgsTargetType { + folder('folder'), + strategy('strategy'); + + const SharesCreateArgsTargetType(this.wireName); + final String wireName; + + static SharesCreateArgsTargetType fromWireName(String wireName, String path) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown SharesCreateArgsTargetType $wireName', + ); + } +} + +enum StrategiesListForFolderResultItemAttackLabel { + attack('Attack'), + defend('Defend'), + mixed('Mixed'), + unknown('Unknown'); + + const StrategiesListForFolderResultItemAttackLabel(this.wireName); + final String wireName; + + static StrategiesListForFolderResultItemAttackLabel fromWireName( + String wireName, + String path, + ) { + for (final value in values) { + if (value.wireName == wireName) return value; + } + throw ConvexDecodingException( + path, + 'unknown StrategiesListForFolderResultItemAttackLabel $wireName', + ); + } +} + +sealed class ImagesCompleteUploadResult { + const ImagesCompleteUploadResult(); + + factory ImagesCompleteUploadResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['provider'] ?? _missing(path, 'provider'), + '$path.provider', + ); + return switch (discriminator) { + 'convex' => ImagesCompleteUploadResultConvex.decode(value, path), + 'r2' => ImagesCompleteUploadResultR2.decode(value, path), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +sealed class OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItem(); + + factory OpsApplyBatchArgsOpsItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['type'] ?? _missing(path, 'type'), + '$path.type', + ); + return switch (discriminator) { + 'strategy.patch' => OpsApplyBatchArgsOpsItemStrategyPatch.decode( + value, + path, + ), + 'page.add' => OpsApplyBatchArgsOpsItemPageAdd.decode(value, path), + 'page.patch' => OpsApplyBatchArgsOpsItemPagePatch.decode(value, path), + 'page.delete' => OpsApplyBatchArgsOpsItemPageDelete.decode(value, path), + 'page.reorder' => OpsApplyBatchArgsOpsItemPageReorder.decode(value, path), + 'pageContent.patch' => OpsApplyBatchArgsOpsItemPageContentPatch.decode( + value, + path, + ), + 'element.add' => OpsApplyBatchArgsOpsItemElementAdd.decode(value, path), + 'element.patch' => OpsApplyBatchArgsOpsItemElementPatch.decode( + value, + path, + ), + 'element.delete' => OpsApplyBatchArgsOpsItemElementDelete.decode( + value, + path, + ), + 'element.reorder' => OpsApplyBatchArgsOpsItemElementReorder.decode( + value, + path, + ), + 'lineup.add' => OpsApplyBatchArgsOpsItemLineupAdd.decode(value, path), + 'lineup.patch' => OpsApplyBatchArgsOpsItemLineupPatch.decode(value, path), + 'lineup.delete' => OpsApplyBatchArgsOpsItemLineupDelete.decode( + value, + path, + ), + 'lineup.reorder' => OpsApplyBatchArgsOpsItemLineupReorder.decode( + value, + path, + ), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +sealed class OpsApplyBatchResultResultsItem { + const OpsApplyBatchResultResultsItem(); + + factory OpsApplyBatchResultResultsItem.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['status'] ?? _missing(path, 'status'), + '$path.status', + ); + return switch (discriminator) { + 'applied' => OpsApplyBatchResultResultsItemApplied.decode(value, path), + 'noop' => OpsApplyBatchResultResultsItemNoop.decode(value, path), + 'rejected' => OpsApplyBatchResultResultsItemRejected.decode(value, path), + 'failed' => OpsApplyBatchResultResultsItemFailed.decode(value, path), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +sealed class OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrent(); + + factory OpsApplyBatchResultResultsItemRejectedCurrent.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['type'] ?? _missing(path, 'type'), + '$path.type', + ); + return switch (discriminator) { + 'strategy' => + OpsApplyBatchResultResultsItemRejectedCurrentStrategy.decode( + value, + path, + ), + 'page' => OpsApplyBatchResultResultsItemRejectedCurrentPage.decode( + value, + path, + ), + 'pageContent' => + OpsApplyBatchResultResultsItemRejectedCurrentPageContent.decode( + value, + path, + ), + 'element' => OpsApplyBatchResultResultsItemRejectedCurrentElement.decode( + value, + path, + ), + 'lineup' => OpsApplyBatchResultResultsItemRejectedCurrentLineup.decode( + value, + path, + ), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +sealed class SharesRedeemResult { + const SharesRedeemResult(); + + factory SharesRedeemResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['targetType'] ?? _missing(path, 'targetType'), + '$path.targetType', + ); + return switch (discriminator) { + 'strategy' => SharesRedeemResultStrategy.decode(value, path), + 'folder' => SharesRedeemResultFolder.decode(value, path), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +sealed class SharesResolveResult { + const SharesResolveResult(); + + factory SharesResolveResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + final discriminator = _decodeString( + object.value['targetType'] ?? _missing(path, 'targetType'), + '$path.targetType', + ); + return switch (discriminator) { + 'strategy' => SharesResolveResultStrategy.decode(value, path), + 'folder' => SharesResolveResultFolder.decode(value, path), + _ => throw ConvexDecodingException( + path, + 'unknown discriminator $discriminator', + ), + }; + } + + ConvexObject encode(String path); +} + +final class ElementsListForPageResultItem { + const ElementsListForPageResultItem({ + required this.createdAt, + required this.deleted, + required this.elementType, + required this.pagePublicId, + required this.payload, + required this.publicId, + required this.revision, + required this.sortIndex, + required this.strategyPublicId, + required this.updatedAt, + }); + final double createdAt; + final bool deleted; + final ElementsListForPageResultItemElementType elementType; + final String pagePublicId; + final CloudPayload payload; + final String publicId; + final double revision; + final double sortIndex; + final String strategyPublicId; + final double updatedAt; + + factory ElementsListForPageResultItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'deleted', + 'elementType', + 'pagePublicId', + 'payload', + 'publicId', + 'revision', + 'sortIndex', + 'strategyPublicId', + 'updatedAt', + }); + return ElementsListForPageResultItem( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + deleted: _decodeBoolean( + object.value['deleted'] ?? _missing(path, 'deleted'), + '$path.deleted', + ), + elementType: ElementsListForPageResultItemElementType.fromWireName( + _decodeString( + object.value['elementType'] ?? _missing(path, 'elementType'), + '$path.elementType', + ), + '$path.elementType', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: _decodeElementsListForPageResultItemPayload( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'deleted': ConvexBoolean(deleted), + 'elementType': ConvexString(elementType.wireName), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': _encodeElementsListForPageResultItemPayload( + payload, + '$path.payload', + ), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + 'strategyPublicId': ConvexString(strategyPublicId), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class FoldersDeleteResult { + const FoldersDeleteResult({required this.ok}); + final bool ok; + + factory FoldersDeleteResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'ok'}); + return FoldersDeleteResult( + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + }); + } +} + +final class FoldersListTreeResultItem { + const FoldersListTreeResultItem({ + required this.agentTypes, + required this.color, + required this.createdAt, + required this.customColorValue, + required this.iconCodePoint, + required this.iconFontFamily, + required this.iconFontPackage, + required this.iconId, + required this.mapPeeks, + required this.name, + required this.parentFolderPublicId, + required this.publicId, + required this.role, + required this.strategyCount, + required this.updatedAt, + }); + final List agentTypes; + final String? color; + final double createdAt; + final double? customColorValue; + final double? iconCodePoint; + final String? iconFontFamily; + final String? iconFontPackage; + final double? iconId; + final List mapPeeks; + final String name; + final String? parentFolderPublicId; + final String publicId; + final FoldersListTreeResultItemRole role; + final double strategyCount; + final double updatedAt; + + factory FoldersListTreeResultItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'agentTypes', + 'color', + 'createdAt', + 'customColorValue', + 'iconCodePoint', + 'iconFontFamily', + 'iconFontPackage', + 'iconId', + 'mapPeeks', + 'name', + 'parentFolderPublicId', + 'publicId', + 'role', + 'strategyCount', + 'updatedAt', + }); + return FoldersListTreeResultItem( + agentTypes: + _decodeArray( + object.value['agentTypes'] ?? _missing(path, 'agentTypes'), + '$path.agentTypes', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.agentTypes', entry.$1), + ), + ) + .toList(growable: false), + color: (object.value['color'] ?? _missing(path, 'color')) is ConvexNull + ? null + : _decodeString( + object.value['color'] ?? _missing(path, 'color'), + '$path.color', + ), + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + customColorValue: + (object.value['customColorValue'] ?? + _missing(path, 'customColorValue')) + is ConvexNull + ? null + : _decodeNumber( + object.value['customColorValue'] ?? + _missing(path, 'customColorValue'), + '$path.customColorValue', + ), + iconCodePoint: + (object.value['iconCodePoint'] ?? _missing(path, 'iconCodePoint')) + is ConvexNull + ? null + : _decodeNumber( + object.value['iconCodePoint'] ?? _missing(path, 'iconCodePoint'), + '$path.iconCodePoint', + ), + iconFontFamily: + (object.value['iconFontFamily'] ?? _missing(path, 'iconFontFamily')) + is ConvexNull + ? null + : _decodeString( + object.value['iconFontFamily'] ?? + _missing(path, 'iconFontFamily'), + '$path.iconFontFamily', + ), + iconFontPackage: + (object.value['iconFontPackage'] ?? _missing(path, 'iconFontPackage')) + is ConvexNull + ? null + : _decodeString( + object.value['iconFontPackage'] ?? + _missing(path, 'iconFontPackage'), + '$path.iconFontPackage', + ), + iconId: (object.value['iconId'] ?? _missing(path, 'iconId')) is ConvexNull + ? null + : _decodeNumber( + object.value['iconId'] ?? _missing(path, 'iconId'), + '$path.iconId', + ), + mapPeeks: + _decodeArray( + object.value['mapPeeks'] ?? _missing(path, 'mapPeeks'), + '$path.mapPeeks', + ).value.indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('$path.mapPeeks', entry.$1), + ), + ) + .toList(growable: false), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + parentFolderPublicId: + (object.value['parentFolderPublicId'] ?? + _missing(path, 'parentFolderPublicId')) + is ConvexNull + ? null + : _decodeString( + object.value['parentFolderPublicId'] ?? + _missing(path, 'parentFolderPublicId'), + '$path.parentFolderPublicId', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + strategyCount: _decodeNumber( + object.value['strategyCount'] ?? _missing(path, 'strategyCount'), + '$path.strategyCount', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'agentTypes': ConvexArray( + agentTypes.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), + 'color': color == null ? const ConvexNull() : ConvexString(color!), + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'customColorValue': customColorValue == null + ? const ConvexNull() + : _encodeNumber(customColorValue!, '$path.customColorValue'), + 'iconCodePoint': iconCodePoint == null + ? const ConvexNull() + : _encodeNumber(iconCodePoint!, '$path.iconCodePoint'), + 'iconFontFamily': iconFontFamily == null + ? const ConvexNull() + : ConvexString(iconFontFamily!), + 'iconFontPackage': iconFontPackage == null + ? const ConvexNull() + : ConvexString(iconFontPackage!), + 'iconId': iconId == null + ? const ConvexNull() + : _encodeNumber(iconId!, '$path.iconId'), + 'mapPeeks': ConvexArray( + mapPeeks.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), + 'name': ConvexString(name), + 'parentFolderPublicId': parentFolderPublicId == null + ? const ConvexNull() + : ConvexString(parentFolderPublicId!), + 'publicId': ConvexString(publicId), + 'role': ConvexString(role.wireName), + 'strategyCount': _encodeNumber(strategyCount, '$path.strategyCount'), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class ImagesCompleteUploadResultConvex + extends ImagesCompleteUploadResult { + const ImagesCompleteUploadResultConvex({required this.ok}); + final bool ok; + + factory ImagesCompleteUploadResultConvex.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'provider', 'ok'}); + return ImagesCompleteUploadResultConvex( + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'provider': ConvexString('convex'), + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + }); + } +} + +final class ImagesCompleteUploadResultR2 extends ImagesCompleteUploadResult { + const ImagesCompleteUploadResultR2({required this.ok, required this.url}); + final bool ok; + final String url; + + factory ImagesCompleteUploadResultR2.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'provider', 'ok', 'url'}); + return ImagesCompleteUploadResultR2( + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + url: _decodeString( + object.value['url'] ?? _missing(path, 'url'), + '$path.url', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'provider': ConvexString('r2'), + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + 'url': ConvexString(url), + }); + } +} + +final class ImagesGenerateUploadUrlResult { + const ImagesGenerateUploadUrlResult({ + required this.expiresAt, + required this.maxBytes, + required this.objectKey, + required this.provider, + required this.requiredHeaders, + required this.uploadId, + required this.uploadUrl, + }); + final double expiresAt; + final double maxBytes; + final String objectKey; + final ImagesGenerateUploadUrlResultProvider provider; + final Map requiredHeaders; + final String uploadId; + final String uploadUrl; + + factory ImagesGenerateUploadUrlResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'expiresAt', + 'maxBytes', + 'objectKey', + 'provider', + 'requiredHeaders', + 'uploadId', + 'uploadUrl', + }); + return ImagesGenerateUploadUrlResult( + expiresAt: _decodeNumber( + object.value['expiresAt'] ?? _missing(path, 'expiresAt'), + '$path.expiresAt', + ), + maxBytes: _decodeNumber( + object.value['maxBytes'] ?? _missing(path, 'maxBytes'), + '$path.maxBytes', + ), + objectKey: _decodeString( + object.value['objectKey'] ?? _missing(path, 'objectKey'), + '$path.objectKey', + ), + provider: ImagesGenerateUploadUrlResultProvider.fromWireName( + _decodeString( + object.value['provider'] ?? _missing(path, 'provider'), + '$path.provider', + ), + '$path.provider', + ), + requiredHeaders: Map.unmodifiable( + _decodeObject( + object.value['requiredHeaders'] ?? _missing(path, 'requiredHeaders'), + '$path.requiredHeaders', + ).value.map( + (key, item) => MapEntry( + key, + _decodeString(item, _fieldPath('$path.requiredHeaders', key)), + ), + ), + ), + uploadId: _decodeString( + object.value['uploadId'] ?? _missing(path, 'uploadId'), + '$path.uploadId', + ), + uploadUrl: _decodeString( + object.value['uploadUrl'] ?? _missing(path, 'uploadUrl'), + '$path.uploadUrl', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'expiresAt': _encodeNumber(expiresAt, '$path.expiresAt'), + 'maxBytes': _encodeNumber(maxBytes, '$path.maxBytes'), + 'objectKey': ConvexString(objectKey), + 'provider': ConvexString(provider.wireName), + 'requiredHeaders': ConvexObject( + requiredHeaders.map((key, item) => MapEntry(key, ConvexString(item))), + ), + 'uploadId': ConvexString(uploadId), + 'uploadUrl': ConvexString(uploadUrl), + }); + } +} + +final class ImagesGetAssetUrlResult { + const ImagesGetAssetUrlResult({required this.url}); + final String? url; + + factory ImagesGetAssetUrlResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'url'}); + return ImagesGetAssetUrlResult( + url: (object.value['url'] ?? _missing(path, 'url')) is ConvexNull + ? null + : _decodeString( + object.value['url'] ?? _missing(path, 'url'), + '$path.url', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'url': url == null ? const ConvexNull() : ConvexString(url!), + }); + } +} + +final class ImagesListForStrategyResultItem { + const ImagesListForStrategyResultItem({ + required this.byteSize, + required this.fileExtension, + required this.height, + required this.legacyStoragePath, + required this.mimeType, + required this.provider, + required this.publicId, + required this.uploadedAt, + required this.uploadStatus, + required this.url, + required this.width, + }); + final double? byteSize; + final String fileExtension; + final double? height; + final String? legacyStoragePath; + final String? mimeType; + final ImagesCompleteUploadArgsProvider provider; + final String publicId; + final double? uploadedAt; + final ImagesListForStrategyResultItemUploadStatus uploadStatus; + final String? url; + final double? width; + + factory ImagesListForStrategyResultItem.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'byteSize', + 'fileExtension', + 'height', + 'legacyStoragePath', + 'mimeType', + 'provider', + 'publicId', + 'uploadedAt', + 'uploadStatus', + 'url', + 'width', + }); + return ImagesListForStrategyResultItem( + byteSize: + (object.value['byteSize'] ?? _missing(path, 'byteSize')) is ConvexNull + ? null + : _decodeNumber( + object.value['byteSize'] ?? _missing(path, 'byteSize'), + '$path.byteSize', + ), + fileExtension: _decodeString( + object.value['fileExtension'] ?? _missing(path, 'fileExtension'), + '$path.fileExtension', + ), + height: (object.value['height'] ?? _missing(path, 'height')) is ConvexNull + ? null + : _decodeNumber( + object.value['height'] ?? _missing(path, 'height'), + '$path.height', + ), + legacyStoragePath: + (object.value['legacyStoragePath'] ?? + _missing(path, 'legacyStoragePath')) + is ConvexNull + ? null + : _decodeString( + object.value['legacyStoragePath'] ?? + _missing(path, 'legacyStoragePath'), + '$path.legacyStoragePath', + ), + mimeType: + (object.value['mimeType'] ?? _missing(path, 'mimeType')) is ConvexNull + ? null + : _decodeString( + object.value['mimeType'] ?? _missing(path, 'mimeType'), + '$path.mimeType', + ), + provider: ImagesCompleteUploadArgsProvider.fromWireName( + _decodeString( + object.value['provider'] ?? _missing(path, 'provider'), + '$path.provider', + ), + '$path.provider', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + uploadedAt: + (object.value['uploadedAt'] ?? _missing(path, 'uploadedAt')) + is ConvexNull + ? null + : _decodeNumber( + object.value['uploadedAt'] ?? _missing(path, 'uploadedAt'), + '$path.uploadedAt', + ), + uploadStatus: ImagesListForStrategyResultItemUploadStatus.fromWireName( + _decodeString( + object.value['uploadStatus'] ?? _missing(path, 'uploadStatus'), + '$path.uploadStatus', + ), + '$path.uploadStatus', + ), + url: (object.value['url'] ?? _missing(path, 'url')) is ConvexNull + ? null + : _decodeString( + object.value['url'] ?? _missing(path, 'url'), + '$path.url', + ), + width: (object.value['width'] ?? _missing(path, 'width')) is ConvexNull + ? null + : _decodeNumber( + object.value['width'] ?? _missing(path, 'width'), + '$path.width', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'byteSize': byteSize == null + ? const ConvexNull() + : _encodeNumber(byteSize!, '$path.byteSize'), + 'fileExtension': ConvexString(fileExtension), + 'height': height == null + ? const ConvexNull() + : _encodeNumber(height!, '$path.height'), + 'legacyStoragePath': legacyStoragePath == null + ? const ConvexNull() + : ConvexString(legacyStoragePath!), + 'mimeType': mimeType == null + ? const ConvexNull() + : ConvexString(mimeType!), + 'provider': ConvexString(provider.wireName), + 'publicId': ConvexString(publicId), + 'uploadedAt': uploadedAt == null + ? const ConvexNull() + : _encodeNumber(uploadedAt!, '$path.uploadedAt'), + 'uploadStatus': ConvexString(uploadStatus.wireName), + 'url': url == null ? const ConvexNull() : ConvexString(url!), + 'width': width == null + ? const ConvexNull() + : _encodeNumber(width!, '$path.width'), + }); + } +} + +final class InvitesRedeemResult { + const InvitesRedeemResult({ + required this.ok, + required this.role, + required this.strategyPublicId, + }); + final bool ok; + final FoldersListTreeResultItemRole role; + final String strategyPublicId; + + factory InvitesRedeemResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'ok', 'role', 'strategyPublicId'}); + return InvitesRedeemResult( + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + 'role': ConvexString(role.wireName), + 'strategyPublicId': ConvexString(strategyPublicId), + }); + } +} + +final class LineupsListForPageResultItem { + const LineupsListForPageResultItem({ + required this.createdAt, + required this.deleted, + required this.pagePublicId, + required this.payload, + required this.publicId, + required this.revision, + required this.sortIndex, + required this.strategyPublicId, + required this.updatedAt, + }); + final double createdAt; + final bool deleted; + final String pagePublicId; + final CloudPayload payload; + final String publicId; + final double revision; + final double sortIndex; + final String strategyPublicId; + final double updatedAt; + + factory LineupsListForPageResultItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'deleted', + 'pagePublicId', + 'payload', + 'publicId', + 'revision', + 'sortIndex', + 'strategyPublicId', + 'updatedAt', + }); + return LineupsListForPageResultItem( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + deleted: _decodeBoolean( + object.value['deleted'] ?? _missing(path, 'deleted'), + '$path.deleted', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: _decodePayload( + () => const LineupsConvexCodec().decode( + object.value['payload'] ?? _missing(path, 'payload'), + ), + '$path.payload', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'deleted': ConvexBoolean(deleted), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': _encodePayload( + () => const LineupsConvexCodec().encode(payload), + '$path.payload', + ), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + 'strategyPublicId': ConvexString(strategyPublicId), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemElementAdd + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemElementAdd({ + required this.elementPublicId, + required this.opId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + this.expectedElementRevision = const ConvexOptional.absent(), + }); + final String elementPublicId; + final ConvexOptional expectedElementRevision; + final String opId; + final String pagePublicId; + final CloudPayload payload; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemElementAdd.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'elementPublicId', + 'expectedElementRevision', + 'opId', + 'pagePublicId', + 'payload', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemElementAdd( + elementPublicId: _decodeString( + object.value['elementPublicId'] ?? _missing(path, 'elementPublicId'), + '$path.elementPublicId', + ), + expectedElementRevision: + object.value.containsKey('expectedElementRevision') + ? ConvexOptional.present( + _decodeNumber( + object.value['expectedElementRevision']!, + '$path.expectedElementRevision', + ), + ) + : const ConvexOptional.absent(), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: _decodeOpsApplyBatchArgsOpsItemElementAddPayload( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('element.add'), + 'elementPublicId': ConvexString(elementPublicId), + if (expectedElementRevision.isPresent) + 'expectedElementRevision': _encodeNumber( + expectedElementRevision.value, + '$path.expectedElementRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': _encodeOpsApplyBatchArgsOpsItemElementAddPayload( + payload, + '$path.payload', + ), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemElementDelete + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemElementDelete({ + required this.elementPublicId, + required this.expectedElementRevision, + required this.opId, + required this.pagePublicId, + }); + final String elementPublicId; + final double expectedElementRevision; + final String opId; + final String pagePublicId; + + factory OpsApplyBatchArgsOpsItemElementDelete.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'elementPublicId', + 'expectedElementRevision', + 'opId', + 'pagePublicId', + }); + return OpsApplyBatchArgsOpsItemElementDelete( + elementPublicId: _decodeString( + object.value['elementPublicId'] ?? _missing(path, 'elementPublicId'), + '$path.elementPublicId', + ), + expectedElementRevision: _decodeNumber( + object.value['expectedElementRevision'] ?? + _missing(path, 'expectedElementRevision'), + '$path.expectedElementRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('element.delete'), + 'elementPublicId': ConvexString(elementPublicId), + 'expectedElementRevision': _encodeNumber( + expectedElementRevision, + '$path.expectedElementRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + }); + } +} + +final class OpsApplyBatchArgsOpsItemElementPatch + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemElementPatch({ + required this.elementPublicId, + required this.expectedElementRevision, + required this.opId, + this.pagePublicId = const ConvexOptional.absent(), + this.payload = const ConvexOptional.absent(), + this.sortIndex = const ConvexOptional.absent(), + }); + final String elementPublicId; + final double expectedElementRevision; + final String opId; + final ConvexOptional pagePublicId; + final ConvexOptional payload; + final ConvexOptional sortIndex; + + factory OpsApplyBatchArgsOpsItemElementPatch.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'elementPublicId', + 'expectedElementRevision', + 'opId', + 'pagePublicId', + 'payload', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemElementPatch( + elementPublicId: _decodeString( + object.value['elementPublicId'] ?? _missing(path, 'elementPublicId'), + '$path.elementPublicId', + ), + expectedElementRevision: _decodeNumber( + object.value['expectedElementRevision'] ?? + _missing(path, 'expectedElementRevision'), + '$path.expectedElementRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: object.value.containsKey('pagePublicId') + ? ConvexOptional.present( + _decodeString( + object.value['pagePublicId']!, + '$path.pagePublicId', + ), + ) + : const ConvexOptional.absent(), + payload: object.value.containsKey('payload') + ? ConvexOptional.present( + _decodeOpsApplyBatchArgsOpsItemElementPatchPayload( + object.value['payload']!, + '$path.payload', + ), + ) + : const ConvexOptional.absent(), + sortIndex: object.value.containsKey('sortIndex') + ? ConvexOptional.present( + _decodeNumber(object.value['sortIndex']!, '$path.sortIndex'), + ) + : const ConvexOptional.absent(), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('element.patch'), + 'elementPublicId': ConvexString(elementPublicId), + 'expectedElementRevision': _encodeNumber( + expectedElementRevision, + '$path.expectedElementRevision', + ), + 'opId': ConvexString(opId), + if (pagePublicId.isPresent) + 'pagePublicId': ConvexString(pagePublicId.value), + if (payload.isPresent) + 'payload': _encodeOpsApplyBatchArgsOpsItemElementPatchPayload( + payload.value, + '$path.payload', + ), + if (sortIndex.isPresent) + 'sortIndex': _encodeNumber(sortIndex.value, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemElementReorder + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemElementReorder({ + required this.elementPublicId, + required this.expectedElementRevision, + required this.opId, + required this.pagePublicId, + required this.sortIndex, + }); + final String elementPublicId; + final double expectedElementRevision; + final String opId; + final String pagePublicId; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemElementReorder.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'elementPublicId', + 'expectedElementRevision', + 'opId', + 'pagePublicId', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemElementReorder( + elementPublicId: _decodeString( + object.value['elementPublicId'] ?? _missing(path, 'elementPublicId'), + '$path.elementPublicId', + ), + expectedElementRevision: _decodeNumber( + object.value['expectedElementRevision'] ?? + _missing(path, 'expectedElementRevision'), + '$path.expectedElementRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('element.reorder'), + 'elementPublicId': ConvexString(elementPublicId), + 'expectedElementRevision': _encodeNumber( + expectedElementRevision, + '$path.expectedElementRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemLineupAdd extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemLineupAdd({ + required this.lineupPublicId, + required this.opId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + this.expectedLineupRevision = const ConvexOptional.absent(), + }); + final ConvexOptional expectedLineupRevision; + final String lineupPublicId; + final String opId; + final String pagePublicId; + final CloudPayload payload; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemLineupAdd.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedLineupRevision', + 'lineupPublicId', + 'opId', + 'pagePublicId', + 'payload', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemLineupAdd( + expectedLineupRevision: object.value.containsKey('expectedLineupRevision') + ? ConvexOptional.present( + _decodeNumber( + object.value['expectedLineupRevision']!, + '$path.expectedLineupRevision', + ), + ) + : const ConvexOptional.absent(), + lineupPublicId: _decodeString( + object.value['lineupPublicId'] ?? _missing(path, 'lineupPublicId'), + '$path.lineupPublicId', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: _decodeOpsApplyBatchArgsOpsItemLineupAddPayload( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('lineup.add'), + if (expectedLineupRevision.isPresent) + 'expectedLineupRevision': _encodeNumber( + expectedLineupRevision.value, + '$path.expectedLineupRevision', + ), + 'lineupPublicId': ConvexString(lineupPublicId), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': _encodeOpsApplyBatchArgsOpsItemLineupAddPayload( + payload, + '$path.payload', + ), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemLineupDelete + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemLineupDelete({ + required this.expectedLineupRevision, + required this.lineupPublicId, + required this.opId, + required this.pagePublicId, + }); + final double expectedLineupRevision; + final String lineupPublicId; + final String opId; + final String pagePublicId; + + factory OpsApplyBatchArgsOpsItemLineupDelete.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedLineupRevision', + 'lineupPublicId', + 'opId', + 'pagePublicId', + }); + return OpsApplyBatchArgsOpsItemLineupDelete( + expectedLineupRevision: _decodeNumber( + object.value['expectedLineupRevision'] ?? + _missing(path, 'expectedLineupRevision'), + '$path.expectedLineupRevision', + ), + lineupPublicId: _decodeString( + object.value['lineupPublicId'] ?? _missing(path, 'lineupPublicId'), + '$path.lineupPublicId', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('lineup.delete'), + 'expectedLineupRevision': _encodeNumber( + expectedLineupRevision, + '$path.expectedLineupRevision', + ), + 'lineupPublicId': ConvexString(lineupPublicId), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + }); + } +} + +final class OpsApplyBatchArgsOpsItemLineupPatch + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemLineupPatch({ + required this.expectedLineupRevision, + required this.lineupPublicId, + required this.opId, + this.pagePublicId = const ConvexOptional.absent(), + this.payload = const ConvexOptional.absent(), + this.sortIndex = const ConvexOptional.absent(), + }); + final double expectedLineupRevision; + final String lineupPublicId; + final String opId; + final ConvexOptional pagePublicId; + final ConvexOptional payload; + final ConvexOptional sortIndex; + + factory OpsApplyBatchArgsOpsItemLineupPatch.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedLineupRevision', + 'lineupPublicId', + 'opId', + 'pagePublicId', + 'payload', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemLineupPatch( + expectedLineupRevision: _decodeNumber( + object.value['expectedLineupRevision'] ?? + _missing(path, 'expectedLineupRevision'), + '$path.expectedLineupRevision', + ), + lineupPublicId: _decodeString( + object.value['lineupPublicId'] ?? _missing(path, 'lineupPublicId'), + '$path.lineupPublicId', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: object.value.containsKey('pagePublicId') + ? ConvexOptional.present( + _decodeString( + object.value['pagePublicId']!, + '$path.pagePublicId', + ), + ) + : const ConvexOptional.absent(), + payload: object.value.containsKey('payload') + ? ConvexOptional.present( + _decodeOpsApplyBatchArgsOpsItemLineupPatchPayload( + object.value['payload']!, + '$path.payload', + ), + ) + : const ConvexOptional.absent(), + sortIndex: object.value.containsKey('sortIndex') + ? ConvexOptional.present( + _decodeNumber(object.value['sortIndex']!, '$path.sortIndex'), + ) + : const ConvexOptional.absent(), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('lineup.patch'), + 'expectedLineupRevision': _encodeNumber( + expectedLineupRevision, + '$path.expectedLineupRevision', + ), + 'lineupPublicId': ConvexString(lineupPublicId), + 'opId': ConvexString(opId), + if (pagePublicId.isPresent) + 'pagePublicId': ConvexString(pagePublicId.value), + if (payload.isPresent) + 'payload': _encodeOpsApplyBatchArgsOpsItemLineupPatchPayload( + payload.value, + '$path.payload', + ), + if (sortIndex.isPresent) + 'sortIndex': _encodeNumber(sortIndex.value, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemLineupReorder + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemLineupReorder({ + required this.expectedLineupRevision, + required this.lineupPublicId, + required this.opId, + required this.pagePublicId, + required this.sortIndex, + }); + final double expectedLineupRevision; + final String lineupPublicId; + final String opId; + final String pagePublicId; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemLineupReorder.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedLineupRevision', + 'lineupPublicId', + 'opId', + 'pagePublicId', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemLineupReorder( + expectedLineupRevision: _decodeNumber( + object.value['expectedLineupRevision'] ?? + _missing(path, 'expectedLineupRevision'), + '$path.expectedLineupRevision', + ), + lineupPublicId: _decodeString( + object.value['lineupPublicId'] ?? _missing(path, 'lineupPublicId'), + '$path.lineupPublicId', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('lineup.reorder'), + 'expectedLineupRevision': _encodeNumber( + expectedLineupRevision, + '$path.expectedLineupRevision', + ), + 'lineupPublicId': ConvexString(lineupPublicId), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageAdd extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemPageAdd({ + required this.expectedStrategyRevision, + required this.opId, + required this.pagePublicId, + required this.payload, + required this.sortIndex, + }); + final double expectedStrategyRevision; + final String opId; + final String pagePublicId; + final OpsApplyBatchArgsOpsItemPageAddPayload payload; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemPageAdd.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedStrategyRevision', + 'opId', + 'pagePublicId', + 'payload', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemPageAdd( + expectedStrategyRevision: _decodeNumber( + object.value['expectedStrategyRevision'] ?? + _missing(path, 'expectedStrategyRevision'), + '$path.expectedStrategyRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: OpsApplyBatchArgsOpsItemPageAddPayload.decode( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('page.add'), + 'expectedStrategyRevision': _encodeNumber( + expectedStrategyRevision, + '$path.expectedStrategyRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': payload.encode('$path.payload'), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageAddPayload { + const OpsApplyBatchArgsOpsItemPageAddPayload({ + this.isAttack = const ConvexOptional.absent(), + this.isAutoNamed = const ConvexOptional.absent(), + this.name = const ConvexOptional.absent(), + this.settings = const ConvexOptional.absent(), + }); + final ConvexOptional isAttack; + final ConvexOptional isAutoNamed; + final ConvexOptional name; + final ConvexOptional settings; + + factory OpsApplyBatchArgsOpsItemPageAddPayload.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'isAttack', + 'isAutoNamed', + 'name', + 'settings', + }); + return OpsApplyBatchArgsOpsItemPageAddPayload( + isAttack: object.value.containsKey('isAttack') + ? ConvexOptional.present( + _decodeBoolean(object.value['isAttack']!, '$path.isAttack'), + ) + : const ConvexOptional.absent(), + isAutoNamed: object.value.containsKey('isAutoNamed') + ? ConvexOptional.present( + _decodeBoolean(object.value['isAutoNamed']!, '$path.isAutoNamed'), + ) + : const ConvexOptional.absent(), + name: object.value.containsKey('name') + ? ConvexOptional.present( + _decodeString(object.value['name']!, '$path.name'), + ) + : const ConvexOptional.absent(), + settings: object.value.containsKey('settings') + ? ConvexOptional.present( + OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + object.value['settings']!, + '$path.settings', + ), + ) + : const ConvexOptional.absent(), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + if (isAttack.isPresent) 'isAttack': ConvexBoolean(isAttack.value), + if (isAutoNamed.isPresent) + 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + if (name.isPresent) 'name': ConvexString(name.value), + if (settings.isPresent) + 'settings': settings.value.encode('$path.settings'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageAddPayloadSettings { + const OpsApplyBatchArgsOpsItemPageAddPayloadSettings({ + required this.abilitySize, + required this.agentSize, + required this.useNeutralTeamColors, + }); + final double abilitySize; + final double agentSize; + final bool useNeutralTeamColors; + + factory OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'abilitySize', + 'agentSize', + 'useNeutralTeamColors', + }); + return OpsApplyBatchArgsOpsItemPageAddPayloadSettings( + abilitySize: _decodeNumber( + object.value['abilitySize'] ?? _missing(path, 'abilitySize'), + '$path.abilitySize', + ), + agentSize: _decodeNumber( + object.value['agentSize'] ?? _missing(path, 'agentSize'), + '$path.agentSize', + ), + useNeutralTeamColors: _decodeBoolean( + object.value['useNeutralTeamColors'] ?? + _missing(path, 'useNeutralTeamColors'), + '$path.useNeutralTeamColors', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'abilitySize': _encodeNumber(abilitySize, '$path.abilitySize'), + 'agentSize': _encodeNumber(agentSize, '$path.agentSize'), + 'useNeutralTeamColors': ConvexBoolean(useNeutralTeamColors), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageContentPatch + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemPageContentPatch({ + required this.expectedPageContentRevision, + required this.opId, + required this.pagePublicId, + required this.settings, + }); + final double expectedPageContentRevision; + final String opId; + final String pagePublicId; + final OpsApplyBatchArgsOpsItemPageAddPayloadSettings settings; + + factory OpsApplyBatchArgsOpsItemPageContentPatch.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedPageContentRevision', + 'opId', + 'pagePublicId', + 'settings', + }); + return OpsApplyBatchArgsOpsItemPageContentPatch( + expectedPageContentRevision: _decodeNumber( + object.value['expectedPageContentRevision'] ?? + _missing(path, 'expectedPageContentRevision'), + '$path.expectedPageContentRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + settings: OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + object.value['settings'] ?? _missing(path, 'settings'), + '$path.settings', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('pageContent.patch'), + 'expectedPageContentRevision': _encodeNumber( + expectedPageContentRevision, + '$path.expectedPageContentRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'settings': settings.encode('$path.settings'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageDelete + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemPageDelete({ + required this.expectedStrategyRevision, + required this.opId, + required this.pagePublicId, + }); + final double expectedStrategyRevision; + final String opId; + final String pagePublicId; + + factory OpsApplyBatchArgsOpsItemPageDelete.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedStrategyRevision', + 'opId', + 'pagePublicId', + }); + return OpsApplyBatchArgsOpsItemPageDelete( + expectedStrategyRevision: _decodeNumber( + object.value['expectedStrategyRevision'] ?? + _missing(path, 'expectedStrategyRevision'), + '$path.expectedStrategyRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('page.delete'), + 'expectedStrategyRevision': _encodeNumber( + expectedStrategyRevision, + '$path.expectedStrategyRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPagePatch extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemPagePatch({ + required this.expectedPageRevision, + required this.opId, + required this.pagePublicId, + required this.payload, + }); + final double expectedPageRevision; + final String opId; + final String pagePublicId; + final OpsApplyBatchArgsOpsItemPageAddPayload payload; + + factory OpsApplyBatchArgsOpsItemPagePatch.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedPageRevision', + 'opId', + 'pagePublicId', + 'payload', + }); + return OpsApplyBatchArgsOpsItemPagePatch( + expectedPageRevision: _decodeNumber( + object.value['expectedPageRevision'] ?? + _missing(path, 'expectedPageRevision'), + '$path.expectedPageRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + payload: OpsApplyBatchArgsOpsItemPageAddPayload.decode( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('page.patch'), + 'expectedPageRevision': _encodeNumber( + expectedPageRevision, + '$path.expectedPageRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'payload': payload.encode('$path.payload'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemPageReorder + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemPageReorder({ + required this.expectedStrategyRevision, + required this.opId, + required this.pagePublicId, + required this.sortIndex, + }); + final double expectedStrategyRevision; + final String opId; + final String pagePublicId; + final double sortIndex; + + factory OpsApplyBatchArgsOpsItemPageReorder.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedStrategyRevision', + 'opId', + 'pagePublicId', + 'sortIndex', + }); + return OpsApplyBatchArgsOpsItemPageReorder( + expectedStrategyRevision: _decodeNumber( + object.value['expectedStrategyRevision'] ?? + _missing(path, 'expectedStrategyRevision'), + '$path.expectedStrategyRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + pagePublicId: _decodeString( + object.value['pagePublicId'] ?? _missing(path, 'pagePublicId'), + '$path.pagePublicId', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('page.reorder'), + 'expectedStrategyRevision': _encodeNumber( + expectedStrategyRevision, + '$path.expectedStrategyRevision', + ), + 'opId': ConvexString(opId), + 'pagePublicId': ConvexString(pagePublicId), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemStrategyPatch + extends OpsApplyBatchArgsOpsItem { + const OpsApplyBatchArgsOpsItemStrategyPatch({ + required this.expectedStrategyRevision, + required this.opId, + required this.payload, + }); + final double expectedStrategyRevision; + final String opId; + final OpsApplyBatchArgsOpsItemStrategyPatchPayload payload; + + factory OpsApplyBatchArgsOpsItemStrategyPatch.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'type', + 'expectedStrategyRevision', + 'opId', + 'payload', + }); + return OpsApplyBatchArgsOpsItemStrategyPatch( + expectedStrategyRevision: _decodeNumber( + object.value['expectedStrategyRevision'] ?? + _missing(path, 'expectedStrategyRevision'), + '$path.expectedStrategyRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + payload: OpsApplyBatchArgsOpsItemStrategyPatchPayload.decode( + object.value['payload'] ?? _missing(path, 'payload'), + '$path.payload', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('strategy.patch'), + 'expectedStrategyRevision': _encodeNumber( + expectedStrategyRevision, + '$path.expectedStrategyRevision', + ), + 'opId': ConvexString(opId), + 'payload': payload.encode('$path.payload'), + }); + } +} + +final class OpsApplyBatchArgsOpsItemStrategyPatchPayload { + const OpsApplyBatchArgsOpsItemStrategyPatchPayload({ + this.clearThemeOverridePalette = const ConvexOptional.absent(), + this.clearThemeProfileId = const ConvexOptional.absent(), + this.mapData = const ConvexOptional.absent(), + this.name = const ConvexOptional.absent(), + this.themeOverridePalette = const ConvexOptional.absent(), + this.themeProfileId = const ConvexOptional.absent(), + }); + final ConvexOptional clearThemeOverridePalette; + final ConvexOptional clearThemeProfileId; + final ConvexOptional mapData; + final ConvexOptional name; + final ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette; + final ConvexOptional themeProfileId; + + factory OpsApplyBatchArgsOpsItemStrategyPatchPayload.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'clearThemeOverridePalette', + 'clearThemeProfileId', + 'mapData', + 'name', + 'themeOverridePalette', + 'themeProfileId', + }); + return OpsApplyBatchArgsOpsItemStrategyPatchPayload( + clearThemeOverridePalette: + object.value.containsKey('clearThemeOverridePalette') + ? ConvexOptional.present( + _decodeBoolean( + object.value['clearThemeOverridePalette']!, + '$path.clearThemeOverridePalette', + ), + ) + : const ConvexOptional.absent(), + clearThemeProfileId: object.value.containsKey('clearThemeProfileId') + ? ConvexOptional.present( + _decodeBoolean( + object.value['clearThemeProfileId']!, + '$path.clearThemeProfileId', + ), + ) + : const ConvexOptional.absent(), + mapData: object.value.containsKey('mapData') + ? ConvexOptional.present( + _decodeString(object.value['mapData']!, '$path.mapData'), + ) + : const ConvexOptional.absent(), + name: object.value.containsKey('name') + ? ConvexOptional.present( + _decodeString(object.value['name']!, '$path.name'), + ) + : const ConvexOptional.absent(), + themeOverridePalette: object.value.containsKey('themeOverridePalette') + ? ConvexOptional.present( + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + object.value['themeOverridePalette']!, + '$path.themeOverridePalette', + ), + ) + : const ConvexOptional.absent(), + themeProfileId: object.value.containsKey('themeProfileId') + ? ConvexOptional.present( + _decodeString( + object.value['themeProfileId']!, + '$path.themeProfileId', + ), + ) + : const ConvexOptional.absent(), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + if (clearThemeOverridePalette.isPresent) + 'clearThemeOverridePalette': ConvexBoolean( + clearThemeOverridePalette.value, + ), + if (clearThemeProfileId.isPresent) + 'clearThemeProfileId': ConvexBoolean(clearThemeProfileId.value), + if (mapData.isPresent) 'mapData': ConvexString(mapData.value), + if (name.isPresent) 'name': ConvexString(name.value), + if (themeOverridePalette.isPresent) + 'themeOverridePalette': themeOverridePalette.value.encode( + '$path.themeOverridePalette', + ), + if (themeProfileId.isPresent) + 'themeProfileId': ConvexString(themeProfileId.value), + }); + } +} + +final class OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette { + const OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette({ + required this.baseValue, + required this.detail, + required this.highlight, + }); + final String baseValue; + final String detail; + final String highlight; + + factory OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'base', 'detail', 'highlight'}); + return OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette( + baseValue: _decodeString( + object.value['base'] ?? _missing(path, 'base'), + '$path.base', + ), + detail: _decodeString( + object.value['detail'] ?? _missing(path, 'detail'), + '$path.detail', + ), + highlight: _decodeString( + object.value['highlight'] ?? _missing(path, 'highlight'), + '$path.highlight', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'base': ConvexString(baseValue), + 'detail': ConvexString(detail), + 'highlight': ConvexString(highlight), + }); + } +} + +final class OpsApplyBatchResult { + const OpsApplyBatchResult({ + required this.results, + required this.strategyPublicId, + }); + final List results; + final String strategyPublicId; + + factory OpsApplyBatchResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'results', 'strategyPublicId'}); + return OpsApplyBatchResult( + results: + _decodeArray( + object.value['results'] ?? _missing(path, 'results'), + '$path.results', + ).value.indexed + .map( + (entry) => OpsApplyBatchResultResultsItem.decode( + entry.$2, + _indexPath('$path.results', entry.$1), + ), + ) + .toList(growable: false), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'results': ConvexArray( + results.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.results', entry.$1)), + ) + .toList(growable: false), + ), + 'strategyPublicId': ConvexString(strategyPublicId), + }); + } +} + +final class OpsApplyBatchResultResultsItemApplied + extends OpsApplyBatchResultResultsItem { + const OpsApplyBatchResultResultsItemApplied({ + required this.appliedRevision, + required this.opId, + }); + final double appliedRevision; + final String opId; + + factory OpsApplyBatchResultResultsItemApplied.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'status', + 'appliedRevision', + 'opId', + }); + return OpsApplyBatchResultResultsItemApplied( + appliedRevision: _decodeNumber( + object.value['appliedRevision'] ?? _missing(path, 'appliedRevision'), + '$path.appliedRevision', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'status': ConvexString('applied'), + 'appliedRevision': _encodeNumber( + appliedRevision, + '$path.appliedRevision', + ), + 'opId': ConvexString(opId), + }); + } +} + +final class OpsApplyBatchResultResultsItemFailed + extends OpsApplyBatchResultResultsItem { + const OpsApplyBatchResultResultsItemFailed({ + required this.code, + required this.message, + required this.opId, + required this.rawCode, + }); + final String code; + final String message; + final String opId; + final String rawCode; + + factory OpsApplyBatchResultResultsItemFailed.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'status', + 'code', + 'message', + 'opId', + 'rawCode', + }); + return OpsApplyBatchResultResultsItemFailed( + code: _decodeString( + object.value['code'] ?? _missing(path, 'code'), + '$path.code', + ), + message: _decodeString( + object.value['message'] ?? _missing(path, 'message'), + '$path.message', + ), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + rawCode: _decodeString( + object.value['rawCode'] ?? _missing(path, 'rawCode'), + '$path.rawCode', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'status': ConvexString('failed'), + 'code': ConvexString(code), + 'message': ConvexString(message), + 'opId': ConvexString(opId), + 'rawCode': ConvexString(rawCode), + }); + } +} + +final class OpsApplyBatchResultResultsItemNoop + extends OpsApplyBatchResultResultsItem { + const OpsApplyBatchResultResultsItemNoop({ + required this.opId, + this.currentRevision = const ConvexOptional.absent(), + }); + final ConvexOptional currentRevision; + final String opId; + + factory OpsApplyBatchResultResultsItemNoop.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'status', + 'currentRevision', + 'opId', + }); + return OpsApplyBatchResultResultsItemNoop( + currentRevision: object.value.containsKey('currentRevision') + ? ConvexOptional.present( + _decodeNumber( + object.value['currentRevision']!, + '$path.currentRevision', + ), + ) + : const ConvexOptional.absent(), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'status': ConvexString('noop'), + if (currentRevision.isPresent) + 'currentRevision': _encodeNumber( + currentRevision.value, + '$path.currentRevision', + ), + 'opId': ConvexString(opId), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejected + extends OpsApplyBatchResultResultsItem { + const OpsApplyBatchResultResultsItemRejected({ + required this.opId, + required this.reason, + this.current = const ConvexOptional.absent(), + }); + final ConvexOptional current; + final String opId; + final OpsApplyBatchResultResultsItemRejectedReason reason; + + factory OpsApplyBatchResultResultsItemRejected.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'status', + 'current', + 'opId', + 'reason', + }); + return OpsApplyBatchResultResultsItemRejected( + current: object.value.containsKey('current') + ? ConvexOptional.present( + OpsApplyBatchResultResultsItemRejectedCurrent.decode( + object.value['current']!, + '$path.current', + ), + ) + : const ConvexOptional.absent(), + opId: _decodeString( + object.value['opId'] ?? _missing(path, 'opId'), + '$path.opId', + ), + reason: OpsApplyBatchResultResultsItemRejectedReason.fromWireName( + _decodeString( + object.value['reason'] ?? _missing(path, 'reason'), + '$path.reason', + ), + '$path.reason', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'status': ConvexString('rejected'), + if (current.isPresent) 'current': current.value.encode('$path.current'), + 'opId': ConvexString(opId), + 'reason': ConvexString(reason.wireName), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentElement + extends OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrentElement({ + required this.revision, + required this.value, + }); + final double revision; + final CloudPayload value; + + factory OpsApplyBatchResultResultsItemRejectedCurrentElement.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'type', 'revision', 'value'}); + return OpsApplyBatchResultResultsItemRejectedCurrentElement( + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + value: _decodeOpsApplyBatchResultResultsItemRejectedCurrentElementValue( + object.value['value'] ?? _missing(path, 'value'), + '$path.value', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('element'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'value': _encodeOpsApplyBatchResultResultsItemRejectedCurrentElementValue( + value, + '$path.value', + ), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentLineup + extends OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrentLineup({ + required this.revision, + required this.value, + }); + final double revision; + final CloudPayload value; + + factory OpsApplyBatchResultResultsItemRejectedCurrentLineup.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'type', 'revision', 'value'}); + return OpsApplyBatchResultResultsItemRejectedCurrentLineup( + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + value: _decodePayload( + () => const LineupsConvexCodec().decode( + object.value['value'] ?? _missing(path, 'value'), + ), + '$path.value', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('lineup'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'value': _encodePayload( + () => const LineupsConvexCodec().encode(value), + '$path.value', + ), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentPage + extends OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrentPage({ + required this.revision, + required this.value, + }); + final double revision; + final OpsApplyBatchResultResultsItemRejectedCurrentPageValue value; + + factory OpsApplyBatchResultResultsItemRejectedCurrentPage.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'type', 'revision', 'value'}); + return OpsApplyBatchResultResultsItemRejectedCurrentPage( + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + value: OpsApplyBatchResultResultsItemRejectedCurrentPageValue.decode( + object.value['value'] ?? _missing(path, 'value'), + '$path.value', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('page'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'value': value.encode('$path.value'), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentPageContent + extends OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrentPageContent({ + required this.revision, + required this.value, + }); + final double revision; + final OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue value; + + factory OpsApplyBatchResultResultsItemRejectedCurrentPageContent.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'type', 'revision', 'value'}); + return OpsApplyBatchResultResultsItemRejectedCurrentPageContent( + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + value: + OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue.decode( + object.value['value'] ?? _missing(path, 'value'), + '$path.value', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('pageContent'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'value': value.encode('$path.value'), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue { + const OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue({ + required this.settings, + }); + final OpsApplyBatchArgsOpsItemPageAddPayloadSettings? settings; + + factory OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'settings'}); + return OpsApplyBatchResultResultsItemRejectedCurrentPageContentValue( + settings: + (object.value['settings'] ?? _missing(path, 'settings')) is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + object.value['settings'] ?? _missing(path, 'settings'), + '$path.settings', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'settings': settings == null + ? const ConvexNull() + : settings!.encode('$path.settings'), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentPageValue { + const OpsApplyBatchResultResultsItemRejectedCurrentPageValue({ + required this.isAttack, + required this.name, + required this.sortIndex, + this.isAutoNamed = const ConvexOptional.absent(), + }); + final bool isAttack; + final ConvexOptional isAutoNamed; + final String name; + final double sortIndex; + + factory OpsApplyBatchResultResultsItemRejectedCurrentPageValue.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'isAttack', + 'isAutoNamed', + 'name', + 'sortIndex', + }); + return OpsApplyBatchResultResultsItemRejectedCurrentPageValue( + isAttack: _decodeBoolean( + object.value['isAttack'] ?? _missing(path, 'isAttack'), + '$path.isAttack', + ), + isAutoNamed: object.value.containsKey('isAutoNamed') + ? ConvexOptional.present( + _decodeBoolean(object.value['isAutoNamed']!, '$path.isAutoNamed'), + ) + : const ConvexOptional.absent(), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'isAttack': ConvexBoolean(isAttack), + if (isAutoNamed.isPresent) + 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + 'name': ConvexString(name), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentStrategy + extends OpsApplyBatchResultResultsItemRejectedCurrent { + const OpsApplyBatchResultResultsItemRejectedCurrentStrategy({ + required this.revision, + required this.value, + }); + final double revision; + final OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue value; + + factory OpsApplyBatchResultResultsItemRejectedCurrentStrategy.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'type', 'revision', 'value'}); + return OpsApplyBatchResultResultsItemRejectedCurrentStrategy( + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + value: OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue.decode( + object.value['value'] ?? _missing(path, 'value'), + '$path.value', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'type': ConvexString('strategy'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'value': value.encode('$path.value'), + }); + } +} + +final class OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue { + const OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue({ + required this.mapData, + required this.name, + required this.themeOverridePalette, + required this.themeProfileId, + }); + final String mapData; + final String name; + final OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette? + themeOverridePalette; + final String? themeProfileId; + + factory OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'mapData', + 'name', + 'themeOverridePalette', + 'themeProfileId', + }); + return OpsApplyBatchResultResultsItemRejectedCurrentStrategyValue( + mapData: _decodeString( + object.value['mapData'] ?? _missing(path, 'mapData'), + '$path.mapData', + ), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + themeOverridePalette: + (object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette')) + is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette'), + '$path.themeOverridePalette', + ), + themeProfileId: + (object.value['themeProfileId'] ?? _missing(path, 'themeProfileId')) + is ConvexNull + ? null + : _decodeString( + object.value['themeProfileId'] ?? + _missing(path, 'themeProfileId'), + '$path.themeProfileId', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'mapData': ConvexString(mapData), + 'name': ConvexString(name), + 'themeOverridePalette': themeOverridePalette == null + ? const ConvexNull() + : themeOverridePalette!.encode('$path.themeOverridePalette'), + 'themeProfileId': themeProfileId == null + ? const ConvexNull() + : ConvexString(themeProfileId!), + }); + } +} + +final class PageGetSnapshotResult { + const PageGetSnapshotResult({ + required this.assets, + required this.content, + required this.elements, + required this.lineups, + required this.page, + }); + final List assets; + final PageGetSnapshotResultContent content; + final List elements; + final List lineups; + final PageGetSnapshotResultPage page; + + factory PageGetSnapshotResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'assets', + 'content', + 'elements', + 'lineups', + 'page', + }); + return PageGetSnapshotResult( + assets: + _decodeArray( + object.value['assets'] ?? _missing(path, 'assets'), + '$path.assets', + ).value.indexed + .map( + (entry) => ImagesListForStrategyResultItem.decode( + entry.$2, + _indexPath('$path.assets', entry.$1), + ), + ) + .toList(growable: false), + content: PageGetSnapshotResultContent.decode( + object.value['content'] ?? _missing(path, 'content'), + '$path.content', + ), + elements: + _decodeArray( + object.value['elements'] ?? _missing(path, 'elements'), + '$path.elements', + ).value.indexed + .map( + (entry) => ElementsListForPageResultItem.decode( + entry.$2, + _indexPath('$path.elements', entry.$1), + ), + ) + .toList(growable: false), + lineups: + _decodeArray( + object.value['lineups'] ?? _missing(path, 'lineups'), + '$path.lineups', + ).value.indexed + .map( + (entry) => LineupsListForPageResultItem.decode( + entry.$2, + _indexPath('$path.lineups', entry.$1), + ), + ) + .toList(growable: false), + page: PageGetSnapshotResultPage.decode( + object.value['page'] ?? _missing(path, 'page'), + '$path.page', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'assets': ConvexArray( + assets.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.assets', entry.$1)), + ) + .toList(growable: false), + ), + 'content': content.encode('$path.content'), + 'elements': ConvexArray( + elements.indexed + .map( + (entry) => + entry.$2.encode(_indexPath('$path.elements', entry.$1)), + ) + .toList(growable: false), + ), + 'lineups': ConvexArray( + lineups.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.lineups', entry.$1)), + ) + .toList(growable: false), + ), + 'page': page.encode('$path.page'), + }); + } +} + +final class PageGetSnapshotResultContent { + const PageGetSnapshotResultContent({ + required this.createdAt, + required this.revision, + required this.settings, + required this.updatedAt, + }); + final double createdAt; + final double revision; + final OpsApplyBatchArgsOpsItemPageAddPayloadSettings? settings; + final double updatedAt; + + factory PageGetSnapshotResultContent.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'revision', + 'settings', + 'updatedAt', + }); + return PageGetSnapshotResultContent( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + settings: + (object.value['settings'] ?? _missing(path, 'settings')) is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + object.value['settings'] ?? _missing(path, 'settings'), + '$path.settings', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'revision': _encodeNumber(revision, '$path.revision'), + 'settings': settings == null + ? const ConvexNull() + : settings!.encode('$path.settings'), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class PageGetSnapshotResultPage { + const PageGetSnapshotResultPage({ + required this.createdAt, + required this.isAttack, + required this.name, + required this.publicId, + required this.revision, + required this.sortIndex, + required this.strategyPublicId, + required this.updatedAt, + this.isAutoNamed = const ConvexOptional.absent(), + }); + final double createdAt; + final bool isAttack; + final ConvexOptional isAutoNamed; + final String name; + final String publicId; + final double revision; + final double sortIndex; + final String strategyPublicId; + final double updatedAt; + + factory PageGetSnapshotResultPage.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'isAttack', + 'isAutoNamed', + 'name', + 'publicId', + 'revision', + 'sortIndex', + 'strategyPublicId', + 'updatedAt', + }); + return PageGetSnapshotResultPage( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + isAttack: _decodeBoolean( + object.value['isAttack'] ?? _missing(path, 'isAttack'), + '$path.isAttack', + ), + isAutoNamed: object.value.containsKey('isAutoNamed') + ? ConvexOptional.present( + _decodeBoolean(object.value['isAutoNamed']!, '$path.isAutoNamed'), + ) + : const ConvexOptional.absent(), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'isAttack': ConvexBoolean(isAttack), + if (isAutoNamed.isPresent) + 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + 'strategyPublicId': ConvexString(strategyPublicId), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class PagesListTrashedResultItem { + const PagesListTrashedResultItem({ + required this.deletedAt, + required this.deletedByName, + required this.deletedByYou, + required this.name, + required this.publicId, + required this.restorableUntil, + }); + final double deletedAt; + final String? deletedByName; + final bool deletedByYou; + final String name; + final String publicId; + final double restorableUntil; + + factory PagesListTrashedResultItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'deletedAt', + 'deletedByName', + 'deletedByYou', + 'name', + 'publicId', + 'restorableUntil', + }); + return PagesListTrashedResultItem( + deletedAt: _decodeNumber( + object.value['deletedAt'] ?? _missing(path, 'deletedAt'), + '$path.deletedAt', + ), + deletedByName: + (object.value['deletedByName'] ?? _missing(path, 'deletedByName')) + is ConvexNull + ? null + : _decodeString( + object.value['deletedByName'] ?? _missing(path, 'deletedByName'), + '$path.deletedByName', + ), + deletedByYou: _decodeBoolean( + object.value['deletedByYou'] ?? _missing(path, 'deletedByYou'), + '$path.deletedByYou', + ), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + restorableUntil: _decodeNumber( + object.value['restorableUntil'] ?? _missing(path, 'restorableUntil'), + '$path.restorableUntil', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'deletedAt': _encodeNumber(deletedAt, '$path.deletedAt'), + 'deletedByName': deletedByName == null + ? const ConvexNull() + : ConvexString(deletedByName!), + 'deletedByYou': ConvexBoolean(deletedByYou), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'restorableUntil': _encodeNumber( + restorableUntil, + '$path.restorableUntil', + ), + }); + } +} + +final class PresenceIssueRoomPassResult { + const PresenceIssueRoomPassResult({ + required this.expiresAt, + required this.pass, + required this.url, + }); + final double expiresAt; + final String pass; + final String url; + + factory PresenceIssueRoomPassResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'expiresAt', 'pass', 'url'}); + return PresenceIssueRoomPassResult( + expiresAt: _decodeNumber( + object.value['expiresAt'] ?? _missing(path, 'expiresAt'), + '$path.expiresAt', + ), + pass: _decodeString( + object.value['pass'] ?? _missing(path, 'pass'), + '$path.pass', + ), + url: _decodeString( + object.value['url'] ?? _missing(path, 'url'), + '$path.url', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'expiresAt': _encodeNumber(expiresAt, '$path.expiresAt'), + 'pass': ConvexString(pass), + 'url': ConvexString(url), + }); + } +} + +final class SharesListResultItem { + const SharesListResultItem({ + required this.createdAt, + required this.revokedAt, + required this.role, + required this.token, + }); + final double createdAt; + final double? revokedAt; + final InvitesCreateArgsRole role; + final String token; + + factory SharesListResultItem.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'revokedAt', + 'role', + 'token', + }); + return SharesListResultItem( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + revokedAt: + (object.value['revokedAt'] ?? _missing(path, 'revokedAt')) + is ConvexNull + ? null + : _decodeNumber( + object.value['revokedAt'] ?? _missing(path, 'revokedAt'), + '$path.revokedAt', + ), + role: InvitesCreateArgsRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + token: _decodeString( + object.value['token'] ?? _missing(path, 'token'), + '$path.token', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'revokedAt': revokedAt == null + ? const ConvexNull() + : _encodeNumber(revokedAt!, '$path.revokedAt'), + 'role': ConvexString(role.wireName), + 'token': ConvexString(token), + }); + } +} + +final class SharesRedeemResultFolder extends SharesRedeemResult { + const SharesRedeemResultFolder({ + required this.folderPublicId, + required this.ok, + required this.role, + this.alreadyHadAccess = const ConvexOptional.absent(), + }); + final ConvexOptional alreadyHadAccess; + final String folderPublicId; + final bool ok; + final FoldersListTreeResultItemRole role; + + factory SharesRedeemResultFolder.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'targetType', + 'alreadyHadAccess', + 'folderPublicId', + 'ok', + 'role', + }); + return SharesRedeemResultFolder( + alreadyHadAccess: object.value.containsKey('alreadyHadAccess') + ? ConvexOptional.present( + _decodeBoolean( + object.value['alreadyHadAccess']!, + '$path.alreadyHadAccess', + ), + ) + : const ConvexOptional.absent(), + folderPublicId: _decodeString( + object.value['folderPublicId'] ?? _missing(path, 'folderPublicId'), + '$path.folderPublicId', + ), + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'targetType': ConvexString('folder'), + if (alreadyHadAccess.isPresent) + 'alreadyHadAccess': ConvexBoolean(alreadyHadAccess.value), + 'folderPublicId': ConvexString(folderPublicId), + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + 'role': ConvexString(role.wireName), + }); + } +} + +final class SharesRedeemResultStrategy extends SharesRedeemResult { + const SharesRedeemResultStrategy({ + required this.folderPublicId, + required this.ok, + required this.role, + required this.strategyPublicId, + this.alreadyHadAccess = const ConvexOptional.absent(), + }); + final ConvexOptional alreadyHadAccess; + final String? folderPublicId; + final bool ok; + final FoldersListTreeResultItemRole role; + final String strategyPublicId; + + factory SharesRedeemResultStrategy.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'targetType', + 'alreadyHadAccess', + 'folderPublicId', + 'ok', + 'role', + 'strategyPublicId', + }); + return SharesRedeemResultStrategy( + alreadyHadAccess: object.value.containsKey('alreadyHadAccess') + ? ConvexOptional.present( + _decodeBoolean( + object.value['alreadyHadAccess']!, + '$path.alreadyHadAccess', + ), + ) + : const ConvexOptional.absent(), + folderPublicId: + (object.value['folderPublicId'] ?? _missing(path, 'folderPublicId')) + is ConvexNull + ? null + : _decodeString( + object.value['folderPublicId'] ?? + _missing(path, 'folderPublicId'), + '$path.folderPublicId', + ), + ok: _expectLiteral( + _decodeBoolean(object.value['ok'] ?? _missing(path, 'ok'), '$path.ok'), + true, + '$path.ok', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'targetType': ConvexString('strategy'), + if (alreadyHadAccess.isPresent) + 'alreadyHadAccess': ConvexBoolean(alreadyHadAccess.value), + 'folderPublicId': folderPublicId == null + ? const ConvexNull() + : ConvexString(folderPublicId!), + 'ok': ConvexBoolean(_expectLiteral(ok, true, '$path.ok')), + 'role': ConvexString(role.wireName), + 'strategyPublicId': ConvexString(strategyPublicId), + }); + } +} + +final class SharesResolveResultFolder extends SharesResolveResult { + const SharesResolveResultFolder({required this.role}); + final InvitesCreateArgsRole role; + + factory SharesResolveResultFolder.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'targetType', 'role'}); + return SharesResolveResultFolder( + role: InvitesCreateArgsRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'targetType': ConvexString('folder'), + 'role': ConvexString(role.wireName), + }); + } +} + +final class SharesResolveResultStrategy extends SharesResolveResult { + const SharesResolveResultStrategy({ + required this.role, + required this.strategyPublicId, + }); + final InvitesCreateArgsRole role; + final String strategyPublicId; + + factory SharesResolveResultStrategy.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'targetType', + 'role', + 'strategyPublicId', + }); + return SharesResolveResultStrategy( + role: InvitesCreateArgsRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + ); + } + + @override + ConvexObject encode(String path) { + return ConvexObject({ + 'targetType': ConvexString('strategy'), + 'role': ConvexString(role.wireName), + 'strategyPublicId': ConvexString(strategyPublicId), + }); + } +} + +final class StrategiesGetHeaderResult { + const StrategiesGetHeaderResult({ + required this.createdAt, + required this.mapData, + required this.name, + required this.publicId, + required this.revision, + required this.role, + required this.themeOverridePalette, + required this.themeProfileId, + required this.updatedAt, + }); + final double createdAt; + final String mapData; + final String name; + final String publicId; + final double revision; + final FoldersListTreeResultItemRole role; + final OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette? + themeOverridePalette; + final String? themeProfileId; + final double updatedAt; + + factory StrategiesGetHeaderResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'createdAt', + 'mapData', + 'name', + 'publicId', + 'revision', + 'role', + 'themeOverridePalette', + 'themeProfileId', + 'updatedAt', + }); + return StrategiesGetHeaderResult( + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + mapData: _decodeString( + object.value['mapData'] ?? _missing(path, 'mapData'), + '$path.mapData', + ), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + themeOverridePalette: + (object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette')) + is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette'), + '$path.themeOverridePalette', + ), + themeProfileId: + (object.value['themeProfileId'] ?? _missing(path, 'themeProfileId')) + is ConvexNull + ? null + : _decodeString( + object.value['themeProfileId'] ?? + _missing(path, 'themeProfileId'), + '$path.themeProfileId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'mapData': ConvexString(mapData), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'role': ConvexString(role.wireName), + 'themeOverridePalette': themeOverridePalette == null + ? const ConvexNull() + : themeOverridePalette!.encode('$path.themeOverridePalette'), + 'themeProfileId': themeProfileId == null + ? const ConvexNull() + : ConvexString(themeProfileId!), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class StrategiesListForFolderResultItem { + const StrategiesListForFolderResultItem({ + required this.attackLabel, + required this.createdAt, + required this.folderPublicId, + required this.mapData, + required this.name, + required this.publicId, + required this.revision, + required this.role, + required this.themeOverridePalette, + required this.themeProfileId, + required this.updatedAt, + }); + final StrategiesListForFolderResultItemAttackLabel attackLabel; + final double createdAt; + final String? folderPublicId; + final String mapData; + final String name; + final String publicId; + final double revision; + final FoldersListTreeResultItemRole role; + final OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette? + themeOverridePalette; + final String? themeProfileId; + final double updatedAt; + + factory StrategiesListForFolderResultItem.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'attackLabel', + 'createdAt', + 'folderPublicId', + 'mapData', + 'name', + 'publicId', + 'revision', + 'role', + 'themeOverridePalette', + 'themeProfileId', + 'updatedAt', + }); + return StrategiesListForFolderResultItem( + attackLabel: StrategiesListForFolderResultItemAttackLabel.fromWireName( + _decodeString( + object.value['attackLabel'] ?? _missing(path, 'attackLabel'), + '$path.attackLabel', + ), + '$path.attackLabel', + ), + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + folderPublicId: + (object.value['folderPublicId'] ?? _missing(path, 'folderPublicId')) + is ConvexNull + ? null + : _decodeString( + object.value['folderPublicId'] ?? + _missing(path, 'folderPublicId'), + '$path.folderPublicId', + ), + mapData: _decodeString( + object.value['mapData'] ?? _missing(path, 'mapData'), + '$path.mapData', + ), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + role: FoldersListTreeResultItemRole.fromWireName( + _decodeString( + object.value['role'] ?? _missing(path, 'role'), + '$path.role', + ), + '$path.role', + ), + themeOverridePalette: + (object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette')) + is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette.decode( + object.value['themeOverridePalette'] ?? + _missing(path, 'themeOverridePalette'), + '$path.themeOverridePalette', + ), + themeProfileId: + (object.value['themeProfileId'] ?? _missing(path, 'themeProfileId')) + is ConvexNull + ? null + : _decodeString( + object.value['themeProfileId'] ?? + _missing(path, 'themeProfileId'), + '$path.themeProfileId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'attackLabel': ConvexString(attackLabel.wireName), + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'folderPublicId': folderPublicId == null + ? const ConvexNull() + : ConvexString(folderPublicId!), + 'mapData': ConvexString(mapData), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'role': ConvexString(role.wireName), + 'themeOverridePalette': themeOverridePalette == null + ? const ConvexNull() + : themeOverridePalette!.encode('$path.themeOverridePalette'), + 'themeProfileId': themeProfileId == null + ? const ConvexNull() + : ConvexString(themeProfileId!), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class StrategyGetFullSnapshotResult { + const StrategyGetFullSnapshotResult({ + required this.assets, + required this.elements, + required this.header, + required this.lineups, + required this.pages, + }); + final List assets; + final List elements; + final StrategiesGetHeaderResult header; + final List lineups; + final List pages; + + factory StrategyGetFullSnapshotResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'assets', + 'elements', + 'header', + 'lineups', + 'pages', + }); + return StrategyGetFullSnapshotResult( + assets: + _decodeArray( + object.value['assets'] ?? _missing(path, 'assets'), + '$path.assets', + ).value.indexed + .map( + (entry) => ImagesListForStrategyResultItem.decode( + entry.$2, + _indexPath('$path.assets', entry.$1), + ), + ) + .toList(growable: false), + elements: + _decodeArray( + object.value['elements'] ?? _missing(path, 'elements'), + '$path.elements', + ).value.indexed + .map( + (entry) => ElementsListForPageResultItem.decode( + entry.$2, + _indexPath('$path.elements', entry.$1), + ), + ) + .toList(growable: false), + header: StrategiesGetHeaderResult.decode( + object.value['header'] ?? _missing(path, 'header'), + '$path.header', + ), + lineups: + _decodeArray( + object.value['lineups'] ?? _missing(path, 'lineups'), + '$path.lineups', + ).value.indexed + .map( + (entry) => LineupsListForPageResultItem.decode( + entry.$2, + _indexPath('$path.lineups', entry.$1), + ), + ) + .toList(growable: false), + pages: + _decodeArray( + object.value['pages'] ?? _missing(path, 'pages'), + '$path.pages', + ).value.indexed + .map( + (entry) => StrategyGetFullSnapshotResultPagesItem.decode( + entry.$2, + _indexPath('$path.pages', entry.$1), + ), + ) + .toList(growable: false), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'assets': ConvexArray( + assets.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.assets', entry.$1)), + ) + .toList(growable: false), + ), + 'elements': ConvexArray( + elements.indexed + .map( + (entry) => + entry.$2.encode(_indexPath('$path.elements', entry.$1)), + ) + .toList(growable: false), + ), + 'header': header.encode('$path.header'), + 'lineups': ConvexArray( + lineups.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.lineups', entry.$1)), + ) + .toList(growable: false), + ), + 'pages': ConvexArray( + pages.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.pages', entry.$1)), + ) + .toList(growable: false), + ), + }); + } +} + +final class StrategyGetFullSnapshotResultPagesItem { + const StrategyGetFullSnapshotResultPagesItem({ + required this.contentCreatedAt, + required this.contentRevision, + required this.contentUpdatedAt, + required this.createdAt, + required this.isAttack, + required this.name, + required this.publicId, + required this.revision, + required this.settings, + required this.sortIndex, + required this.strategyPublicId, + required this.updatedAt, + this.isAutoNamed = const ConvexOptional.absent(), + }); + final double contentCreatedAt; + final double contentRevision; + final double contentUpdatedAt; + final double createdAt; + final bool isAttack; + final ConvexOptional isAutoNamed; + final String name; + final String publicId; + final double revision; + final OpsApplyBatchArgsOpsItemPageAddPayloadSettings? settings; + final double sortIndex; + final String strategyPublicId; + final double updatedAt; + + factory StrategyGetFullSnapshotResultPagesItem.decode( + ConvexValue value, + String path, + ) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'contentCreatedAt', + 'contentRevision', + 'contentUpdatedAt', + 'createdAt', + 'isAttack', + 'isAutoNamed', + 'name', + 'publicId', + 'revision', + 'settings', + 'sortIndex', + 'strategyPublicId', + 'updatedAt', + }); + return StrategyGetFullSnapshotResultPagesItem( + contentCreatedAt: _decodeNumber( + object.value['contentCreatedAt'] ?? _missing(path, 'contentCreatedAt'), + '$path.contentCreatedAt', + ), + contentRevision: _decodeNumber( + object.value['contentRevision'] ?? _missing(path, 'contentRevision'), + '$path.contentRevision', + ), + contentUpdatedAt: _decodeNumber( + object.value['contentUpdatedAt'] ?? _missing(path, 'contentUpdatedAt'), + '$path.contentUpdatedAt', + ), + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + isAttack: _decodeBoolean( + object.value['isAttack'] ?? _missing(path, 'isAttack'), + '$path.isAttack', + ), + isAutoNamed: object.value.containsKey('isAutoNamed') + ? ConvexOptional.present( + _decodeBoolean(object.value['isAutoNamed']!, '$path.isAutoNamed'), + ) + : const ConvexOptional.absent(), + name: _decodeString( + object.value['name'] ?? _missing(path, 'name'), + '$path.name', + ), + publicId: _decodeString( + object.value['publicId'] ?? _missing(path, 'publicId'), + '$path.publicId', + ), + revision: _decodeNumber( + object.value['revision'] ?? _missing(path, 'revision'), + '$path.revision', + ), + settings: + (object.value['settings'] ?? _missing(path, 'settings')) is ConvexNull + ? null + : OpsApplyBatchArgsOpsItemPageAddPayloadSettings.decode( + object.value['settings'] ?? _missing(path, 'settings'), + '$path.settings', + ), + sortIndex: _decodeNumber( + object.value['sortIndex'] ?? _missing(path, 'sortIndex'), + '$path.sortIndex', + ), + strategyPublicId: _decodeString( + object.value['strategyPublicId'] ?? _missing(path, 'strategyPublicId'), + '$path.strategyPublicId', + ), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'contentCreatedAt': _encodeNumber( + contentCreatedAt, + '$path.contentCreatedAt', + ), + 'contentRevision': _encodeNumber( + contentRevision, + '$path.contentRevision', + ), + 'contentUpdatedAt': _encodeNumber( + contentUpdatedAt, + '$path.contentUpdatedAt', + ), + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'isAttack': ConvexBoolean(isAttack), + if (isAutoNamed.isPresent) + 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'revision': _encodeNumber(revision, '$path.revision'), + 'settings': settings == null + ? const ConvexNull() + : settings!.encode('$path.settings'), + 'sortIndex': _encodeNumber(sortIndex, '$path.sortIndex'), + 'strategyPublicId': ConvexString(strategyPublicId), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +final class StrategyGetShellResult { + const StrategyGetShellResult({required this.header, required this.pages}); + final StrategiesGetHeaderResult header; + final List pages; + + factory StrategyGetShellResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const {'header', 'pages'}); + return StrategyGetShellResult( + header: StrategiesGetHeaderResult.decode( + object.value['header'] ?? _missing(path, 'header'), + '$path.header', + ), + pages: + _decodeArray( + object.value['pages'] ?? _missing(path, 'pages'), + '$path.pages', + ).value.indexed + .map( + (entry) => PageGetSnapshotResultPage.decode( + entry.$2, + _indexPath('$path.pages', entry.$1), + ), + ) + .toList(growable: false), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'header': header.encode('$path.header'), + 'pages': ConvexArray( + pages.indexed + .map( + (entry) => entry.$2.encode(_indexPath('$path.pages', entry.$1)), + ) + .toList(growable: false), + ), + }); + } +} + +final class UsersMeResult { + const UsersMeResult({ + required this.avatarUrl, + required this.createdAt, + required this.displayName, + required this.externalId, + required this.id, + required this.updatedAt, + }); + final String? avatarUrl; + final double createdAt; + final String displayName; + final String externalId; + final String id; + final double updatedAt; + + factory UsersMeResult.decode(ConvexValue value, String path) { + final object = _decodeObject(value, path); + _checkObjectFields(object, path, const { + 'avatarUrl', + 'createdAt', + 'displayName', + 'externalId', + 'id', + 'updatedAt', + }); + return UsersMeResult( + avatarUrl: + (object.value['avatarUrl'] ?? _missing(path, 'avatarUrl')) + is ConvexNull + ? null + : _decodeString( + object.value['avatarUrl'] ?? _missing(path, 'avatarUrl'), + '$path.avatarUrl', + ), + createdAt: _decodeNumber( + object.value['createdAt'] ?? _missing(path, 'createdAt'), + '$path.createdAt', + ), + displayName: _decodeString( + object.value['displayName'] ?? _missing(path, 'displayName'), + '$path.displayName', + ), + externalId: _decodeString( + object.value['externalId'] ?? _missing(path, 'externalId'), + '$path.externalId', + ), + id: _decodeString(object.value['id'] ?? _missing(path, 'id'), '$path.id'), + updatedAt: _decodeNumber( + object.value['updatedAt'] ?? _missing(path, 'updatedAt'), + '$path.updatedAt', + ), + ); + } + + ConvexObject encode(String path) { + return ConvexObject({ + 'avatarUrl': avatarUrl == null + ? const ConvexNull() + : ConvexString(avatarUrl!), + 'createdAt': _encodeNumber(createdAt, '$path.createdAt'), + 'displayName': ConvexString(displayName), + 'externalId': ConvexString(externalId), + 'id': ConvexString(id), + 'updatedAt': _encodeNumber(updatedAt, '$path.updatedAt'), + }); + } +} + +CloudPayload _decodeElementsListForPageResultItemPayload( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'agent' => _decodePayload( + () => const AgentConvexCodec().decode(value), + path, + ), + 'ability' => _decodePayload( + () => const AbilityConvexCodec().decode(value), + path, + ), + 'drawing' => _decodePayload( + () => const DrawingConvexCodec().decode(value), + path, + ), + 'text' => _decodePayload(() => const TextConvexCodec().decode(value), path), + 'image' => _decodePayload( + () => const ImageConvexCodec().decode(value), + path, + ), + 'utility' => _decodePayload( + () => const UtilityConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeElementsListForPageResultItemPayload( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'agent' => _encodePayload( + () => const AgentConvexCodec().encode(value), + path, + ), + 'ability' => _encodePayload( + () => const AbilityConvexCodec().encode(value), + path, + ), + 'drawing' => _encodePayload( + () => const DrawingConvexCodec().encode(value), + path, + ), + 'text' => _encodePayload(() => const TextConvexCodec().encode(value), path), + 'image' => _encodePayload( + () => const ImageConvexCodec().encode(value), + path, + ), + 'utility' => _encodePayload( + () => const UtilityConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +CloudPayload _decodeOpsApplyBatchArgsOpsItemElementAddPayload( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'agent' => _decodePayload( + () => const AgentConvexCodec().decode(value), + path, + ), + 'ability' => _decodePayload( + () => const AbilityConvexCodec().decode(value), + path, + ), + 'drawing' => _decodePayload( + () => const DrawingConvexCodec().decode(value), + path, + ), + 'text' => _decodePayload(() => const TextConvexCodec().decode(value), path), + 'image' => _decodePayload( + () => const ImageConvexCodec().decode(value), + path, + ), + 'utility' => _decodePayload( + () => const UtilityConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeOpsApplyBatchArgsOpsItemElementAddPayload( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'agent' => _encodePayload( + () => const AgentConvexCodec().encode(value), + path, + ), + 'ability' => _encodePayload( + () => const AbilityConvexCodec().encode(value), + path, + ), + 'drawing' => _encodePayload( + () => const DrawingConvexCodec().encode(value), + path, + ), + 'text' => _encodePayload(() => const TextConvexCodec().encode(value), path), + 'image' => _encodePayload( + () => const ImageConvexCodec().encode(value), + path, + ), + 'utility' => _encodePayload( + () => const UtilityConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +CloudPayload _decodeOpsApplyBatchArgsOpsItemElementPatchPayload( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'agent' => _decodePayload( + () => const AgentConvexCodec().decode(value), + path, + ), + 'ability' => _decodePayload( + () => const AbilityConvexCodec().decode(value), + path, + ), + 'drawing' => _decodePayload( + () => const DrawingConvexCodec().decode(value), + path, + ), + 'text' => _decodePayload(() => const TextConvexCodec().decode(value), path), + 'image' => _decodePayload( + () => const ImageConvexCodec().decode(value), + path, + ), + 'utility' => _decodePayload( + () => const UtilityConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeOpsApplyBatchArgsOpsItemElementPatchPayload( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'agent' => _encodePayload( + () => const AgentConvexCodec().encode(value), + path, + ), + 'ability' => _encodePayload( + () => const AbilityConvexCodec().encode(value), + path, + ), + 'drawing' => _encodePayload( + () => const DrawingConvexCodec().encode(value), + path, + ), + 'text' => _encodePayload(() => const TextConvexCodec().encode(value), path), + 'image' => _encodePayload( + () => const ImageConvexCodec().encode(value), + path, + ), + 'utility' => _encodePayload( + () => const UtilityConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +CloudPayload _decodeOpsApplyBatchArgsOpsItemLineupAddPayload( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'lineups' => _decodePayload( + () => const LineupsConvexCodec().decode(value), + path, + ), + 'lineupOrigin' => _decodePayload( + () => const LineupOriginConvexCodec().decode(value), + path, + ), + 'lineupLanding' => _decodePayload( + () => const LineupLandingConvexCodec().decode(value), + path, + ), + 'lineupLink' => _decodePayload( + () => const LineupLinkConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeOpsApplyBatchArgsOpsItemLineupAddPayload( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'lineups' => _encodePayload( + () => const LineupsConvexCodec().encode(value), + path, + ), + 'lineupOrigin' => _encodePayload( + () => const LineupOriginConvexCodec().encode(value), + path, + ), + 'lineupLanding' => _encodePayload( + () => const LineupLandingConvexCodec().encode(value), + path, + ), + 'lineupLink' => _encodePayload( + () => const LineupLinkConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +CloudPayload _decodeOpsApplyBatchArgsOpsItemLineupPatchPayload( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'lineups' => _decodePayload( + () => const LineupsConvexCodec().decode(value), + path, + ), + 'lineupOrigin' => _decodePayload( + () => const LineupOriginConvexCodec().decode(value), + path, + ), + 'lineupLanding' => _decodePayload( + () => const LineupLandingConvexCodec().decode(value), + path, + ), + 'lineupLink' => _decodePayload( + () => const LineupLinkConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeOpsApplyBatchArgsOpsItemLineupPatchPayload( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'lineups' => _encodePayload( + () => const LineupsConvexCodec().encode(value), + path, + ), + 'lineupOrigin' => _encodePayload( + () => const LineupOriginConvexCodec().encode(value), + path, + ), + 'lineupLanding' => _encodePayload( + () => const LineupLandingConvexCodec().encode(value), + path, + ), + 'lineupLink' => _encodePayload( + () => const LineupLinkConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +CloudPayload _decodeOpsApplyBatchResultResultsItemRejectedCurrentElementValue( + ConvexValue value, + String path, +) { + final object = _decodeObject(value, path); + final tag = _decodeString( + object.value['kind'] ?? _missing(path, 'kind'), + '$path.kind', + ); + return switch (tag) { + 'agent' => _decodePayload( + () => const AgentConvexCodec().decode(value), + path, + ), + 'ability' => _decodePayload( + () => const AbilityConvexCodec().decode(value), + path, + ), + 'drawing' => _decodePayload( + () => const DrawingConvexCodec().decode(value), + path, + ), + 'text' => _decodePayload(() => const TextConvexCodec().decode(value), path), + 'image' => _decodePayload( + () => const ImageConvexCodec().decode(value), + path, + ), + 'utility' => _decodePayload( + () => const UtilityConvexCodec().decode(value), + path, + ), + _ => throw ConvexDecodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +ConvexValue _encodeOpsApplyBatchResultResultsItemRejectedCurrentElementValue( + CloudPayload value, + String path, +) { + final tag = value['kind']; + return switch (tag) { + 'agent' => _encodePayload( + () => const AgentConvexCodec().encode(value), + path, + ), + 'ability' => _encodePayload( + () => const AbilityConvexCodec().encode(value), + path, + ), + 'drawing' => _encodePayload( + () => const DrawingConvexCodec().encode(value), + path, + ), + 'text' => _encodePayload(() => const TextConvexCodec().encode(value), path), + 'image' => _encodePayload( + () => const ImageConvexCodec().encode(value), + path, + ), + 'utility' => _encodePayload( + () => const UtilityConvexCodec().encode(value), + path, + ), + _ => throw ConvexEncodingException( + '$path.kind', + 'unknown payload tag $tag', + ), + }; +} + +bool _validateFoldersCreateResult(ConvexValue value) => + (_matchesRaw0(value) || _matchesRaw2(value)); + +bool _matchesRaw0(ConvexValue value) => + value is ConvexObject && + value.value.keys.every(const {'ok'}.contains) && + (value.value['ok'] != null && _matchesRaw1(value.value['ok']!)); + +bool _matchesRaw1(ConvexValue value) => + value is ConvexBoolean && value.value == true; + +bool _matchesRaw2(ConvexValue value) => + value is ConvexObject && + value.value.keys.every(const {'ok', 'reused'}.contains) && + (value.value['ok'] != null && _matchesRaw1(value.value['ok']!)) && + (value.value['reused'] != null && _matchesRaw1(value.value['reused']!)); + +bool _validateInvitesGetResult(ConvexValue value) => + (_matchesRaw3(value) || _matchesRaw12(value) || _matchesRaw6(value)); + +bool _matchesRaw3(ConvexValue value) => + value is ConvexObject && + value.value.keys.every( + const { + 'createdAt', + 'expiresAt', + 'hasAccessAlready', + 'inviteRole', + 'revoked', + 'strategyPublicId', + 'token', + }.contains, + ) && + (value.value['createdAt'] != null && + _matchesRaw4(value.value['createdAt']!)) && + (value.value['expiresAt'] != null && + _matchesRaw5(value.value['expiresAt']!)) && + (value.value['hasAccessAlready'] != null && + _matchesRaw7(value.value['hasAccessAlready']!)) && + (value.value['inviteRole'] != null && + _matchesRaw8(value.value['inviteRole']!)) && + (value.value['revoked'] != null && _matchesRaw7(value.value['revoked']!)) && + (value.value['strategyPublicId'] != null && + _matchesRaw11(value.value['strategyPublicId']!)) && + (value.value['token'] != null && _matchesRaw11(value.value['token']!)); + +bool _matchesRaw4(ConvexValue value) => + (value is ConvexFloat || value is ConvexInteger); + +bool _matchesRaw5(ConvexValue value) => + (_matchesRaw4(value) || _matchesRaw6(value)); + +bool _matchesRaw6(ConvexValue value) => value is ConvexNull; + +bool _matchesRaw7(ConvexValue value) => value is ConvexBoolean; + +bool _matchesRaw8(ConvexValue value) => + (_matchesRaw9(value) || _matchesRaw10(value)); + +bool _matchesRaw9(ConvexValue value) => + value is ConvexString && value.value == 'editor'; + +bool _matchesRaw10(ConvexValue value) => + value is ConvexString && value.value == 'viewer'; + +bool _matchesRaw11(ConvexValue value) => value is ConvexString; + +bool _matchesRaw12(ConvexValue value) => + value is ConvexArray && value.value.every((item) => _matchesRaw13(item)); + +bool _matchesRaw13(ConvexValue value) => + value is ConvexObject && + value.value.keys.every( + const { + 'createdAt', + 'expiresAt', + 'redeemed', + 'revokedAt', + 'role', + 'token', + }.contains, + ) && + (value.value['createdAt'] != null && + _matchesRaw4(value.value['createdAt']!)) && + (value.value['expiresAt'] != null && + _matchesRaw5(value.value['expiresAt']!)) && + (value.value['redeemed'] != null && + _matchesRaw7(value.value['redeemed']!)) && + (value.value['revokedAt'] != null && + _matchesRaw5(value.value['revokedAt']!)) && + (value.value['role'] != null && _matchesRaw8(value.value['role']!)) && + (value.value['token'] != null && _matchesRaw11(value.value['token']!)); + +bool _validatePagesAddResult(ConvexValue value) => + (_matchesRaw14(value) || _matchesRaw15(value)); + +bool _matchesRaw14(ConvexValue value) => + value is ConvexObject && + value.value.keys.every(const {'ok', 'revision'}.contains) && + (value.value['ok'] != null && _matchesRaw1(value.value['ok']!)) && + (value.value['revision'] != null && _matchesRaw4(value.value['revision']!)); + +bool _matchesRaw15(ConvexValue value) => + value is ConvexObject && + value.value.keys.every(const {'ok', 'reused', 'revision'}.contains) && + (value.value['ok'] != null && _matchesRaw1(value.value['ok']!)) && + (value.value['reused'] != null && _matchesRaw1(value.value['reused']!)) && + (value.value['revision'] != null && _matchesRaw4(value.value['revision']!)); + +ConvexObject encodeElementsListForPageArgs({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'elements.js:listForPage.args.clientProtocolVersion', + ), + 'pagePublicId': ConvexString(pagePublicId), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +List decodeElementsListForPageResult( + ConvexValue value, +) => _decodeArray(value, 'elements.js:listForPage.returns').value.indexed + .map( + (entry) => ElementsListForPageResultItem.decode( + entry.$2, + _indexPath('elements.js:listForPage.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeElementsListForStrategyArgs({ + required double clientProtocolVersion, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'elements.js:listForStrategy.args.clientProtocolVersion', + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +List decodeElementsListForStrategyResult( + ConvexValue value, +) => _decodeArray(value, 'elements.js:listForStrategy.returns').value.indexed + .map( + (entry) => ElementsListForPageResultItem.decode( + entry.$2, + _indexPath('elements.js:listForStrategy.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeFoldersCreateArgs({ + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + required String name, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), + required String publicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:create.args.clientProtocolVersion', + ), + if (color.isPresent) 'color': ConvexString(color.value), + if (customColorValue.isPresent) + 'customColorValue': _encodeNumber( + customColorValue.value, + 'folders.js:create.args.customColorValue', + ), + if (iconCodePoint.isPresent) + 'iconCodePoint': _encodeNumber( + iconCodePoint.value, + 'folders.js:create.args.iconCodePoint', + ), + if (iconFontFamily.isPresent) + 'iconFontFamily': ConvexString(iconFontFamily.value), + if (iconFontPackage.isPresent) + 'iconFontPackage': ConvexString(iconFontPackage.value), + if (iconId.isPresent) + 'iconId': _encodeNumber(iconId.value, 'folders.js:create.args.iconId'), + 'name': ConvexString(name), + if (parentFolderPublicId.isPresent) + 'parentFolderPublicId': ConvexString(parentFolderPublicId.value), + 'publicId': ConvexString(publicId), +}); + +ConvexValue decodeFoldersCreateResult(ConvexValue value) => _decodeRaw( + value, + 'folders.js:create.returns', + _validateFoldersCreateResult, +); + +ConvexObject encodeFoldersDeleteArgs({ + required double clientProtocolVersion, + required String folderPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:delete.args.clientProtocolVersion', + ), + 'folderPublicId': ConvexString(folderPublicId), +}); + +FoldersDeleteResult decodeFoldersDeleteResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'folders.js:delete.returns'); + +ConvexObject encodeFoldersListTreeArgs({ + ConvexOptional scope = + const ConvexOptional.absent(), +}) => ConvexObject({ + if (scope.isPresent) 'scope': ConvexString(scope.value.wireName), +}); + +List decodeFoldersListTreeResult( + ConvexValue value, +) => _decodeArray(value, 'folders.js:listTree.returns').value.indexed + .map( + (entry) => FoldersListTreeResultItem.decode( + entry.$2, + _indexPath('folders.js:listTree.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeFoldersMoveArgs({ + required double clientProtocolVersion, + required String folderPublicId, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:move.args.clientProtocolVersion', + ), + 'folderPublicId': ConvexString(folderPublicId), + if (parentFolderPublicId.isPresent) + 'parentFolderPublicId': ConvexString(parentFolderPublicId.value), +}); + +FoldersDeleteResult decodeFoldersMoveResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'folders.js:move.returns'); + +ConvexObject encodeFoldersUpdateArgs({ + ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), + ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), + ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + required String folderPublicId, + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), +}) => ConvexObject({ + if (clearCustomColorValue.isPresent) + 'clearCustomColorValue': ConvexBoolean(clearCustomColorValue.value), + if (clearIconFontFamily.isPresent) + 'clearIconFontFamily': ConvexBoolean(clearIconFontFamily.value), + if (clearIconFontPackage.isPresent) + 'clearIconFontPackage': ConvexBoolean(clearIconFontPackage.value), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'folders.js:update.args.clientProtocolVersion', + ), + if (color.isPresent) 'color': ConvexString(color.value), + if (customColorValue.isPresent) + 'customColorValue': _encodeNumber( + customColorValue.value, + 'folders.js:update.args.customColorValue', + ), + 'folderPublicId': ConvexString(folderPublicId), + if (iconCodePoint.isPresent) + 'iconCodePoint': _encodeNumber( + iconCodePoint.value, + 'folders.js:update.args.iconCodePoint', + ), + if (iconFontFamily.isPresent) + 'iconFontFamily': ConvexString(iconFontFamily.value), + if (iconFontPackage.isPresent) + 'iconFontPackage': ConvexString(iconFontPackage.value), + if (iconId.isPresent) + 'iconId': _encodeNumber(iconId.value, 'folders.js:update.args.iconId'), + if (name.isPresent) 'name': ConvexString(name.value), +}); + +FoldersDeleteResult decodeFoldersUpdateResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'folders.js:update.returns'); + +ConvexObject encodeHealthPingArgs({ + ConvexOptional clientProtocolVersion = const ConvexOptional.absent(), +}) => ConvexObject({ + if (clientProtocolVersion.isPresent) + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion.value, + 'health.js:ping.args.clientProtocolVersion', + ), +}); + +HealthPingResult decodeHealthPingResult(ConvexValue value) => + HealthPingResult.fromWireName( + _decodeString(value, 'health.js:ping.returns'), + 'health.js:ping.returns', + ); + +ConvexObject encodeImagesCompleteUploadArgs({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional etag = const ConvexOptional.absent(), + ConvexOptional fileExtension = const ConvexOptional.absent(), + ConvexOptional height = const ConvexOptional.absent(), + ConvexOptional mimeType = const ConvexOptional.absent(), + ConvexOptional objectKey = const ConvexOptional.absent(), + ConvexOptional provider = + const ConvexOptional.absent(), + ConvexOptional storageId = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional uploadId = const ConvexOptional.absent(), + ConvexOptional width = const ConvexOptional.absent(), +}) => ConvexObject({ + 'assetPublicId': ConvexString(assetPublicId), + if (byteSize.isPresent) + 'byteSize': _encodeNumber( + byteSize.value, + 'images.js:completeUpload.args.byteSize', + ), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:completeUpload.args.clientProtocolVersion', + ), + if (etag.isPresent) 'etag': ConvexString(etag.value), + if (fileExtension.isPresent) + 'fileExtension': ConvexString(fileExtension.value), + if (height.isPresent) + 'height': _encodeNumber( + height.value, + 'images.js:completeUpload.args.height', + ), + if (mimeType.isPresent) 'mimeType': ConvexString(mimeType.value), + if (objectKey.isPresent) 'objectKey': ConvexString(objectKey.value), + if (provider.isPresent) 'provider': ConvexString(provider.value.wireName), + if (storageId.isPresent) 'storageId': ConvexString(storageId.value), + 'strategyPublicId': ConvexString(strategyPublicId), + if (uploadId.isPresent) 'uploadId': ConvexString(uploadId.value), + if (width.isPresent) + 'width': _encodeNumber(width.value, 'images.js:completeUpload.args.width'), +}); + +ImagesCompleteUploadResult decodeImagesCompleteUploadResult( + ConvexValue value, +) => ImagesCompleteUploadResult.decode( + value, + 'images.js:completeUpload.returns', +); + +ConvexObject encodeImagesDeleteAssetRefArgs({ + required String assetPublicId, + required double clientProtocolVersion, + required String strategyPublicId, +}) => ConvexObject({ + 'assetPublicId': ConvexString(assetPublicId), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:deleteAssetRef.args.clientProtocolVersion', + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +FoldersDeleteResult decodeImagesDeleteAssetRefResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'images.js:deleteAssetRef.returns'); + +ConvexObject encodeImagesGenerateUploadUrlArgs({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + required String fileExtension, + ConvexOptional height = const ConvexOptional.absent(), + required String mimeType, + required String strategyPublicId, + ConvexOptional width = const ConvexOptional.absent(), +}) => ConvexObject({ + 'assetPublicId': ConvexString(assetPublicId), + if (byteSize.isPresent) + 'byteSize': _encodeNumber( + byteSize.value, + 'images.js:generateUploadUrl.args.byteSize', + ), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'images.js:generateUploadUrl.args.clientProtocolVersion', + ), + 'fileExtension': ConvexString(fileExtension), + if (height.isPresent) + 'height': _encodeNumber( + height.value, + 'images.js:generateUploadUrl.args.height', + ), + 'mimeType': ConvexString(mimeType), + 'strategyPublicId': ConvexString(strategyPublicId), + if (width.isPresent) + 'width': _encodeNumber( + width.value, + 'images.js:generateUploadUrl.args.width', + ), +}); + +ImagesGenerateUploadUrlResult decodeImagesGenerateUploadUrlResult( + ConvexValue value, +) => ImagesGenerateUploadUrlResult.decode( + value, + 'images.js:generateUploadUrl.returns', +); + +ConvexObject encodeImagesGetAssetUrlArgs({ + required String assetPublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, +}) => ConvexObject({ + 'assetPublicId': ConvexString(assetPublicId), + if (shareToken.isPresent) 'shareToken': ConvexString(shareToken.value), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ImagesGetAssetUrlResult decodeImagesGetAssetUrlResult(ConvexValue value) => + ImagesGetAssetUrlResult.decode(value, 'images.js:getAssetUrl.returns'); + +ConvexObject encodeImagesListForStrategyArgs({ + required String strategyPublicId, +}) => ConvexObject({'strategyPublicId': ConvexString(strategyPublicId)}); + +List decodeImagesListForStrategyResult( + ConvexValue value, +) => _decodeArray(value, 'images.js:listForStrategy.returns').value.indexed + .map( + (entry) => ImagesListForStrategyResultItem.decode( + entry.$2, + _indexPath('images.js:listForStrategy.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeImagesListReferencedAssetIdsArgs({ + required List assetPublicIds, + required String strategyPublicId, +}) => ConvexObject({ + 'assetPublicIds': ConvexArray( + assetPublicIds.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +List? decodeImagesListReferencedAssetIdsResult(ConvexValue value) => + (value) is ConvexNull + ? null + : _decodeArray(value, 'images.js:listReferencedAssetIds.returns') + .value + .indexed + .map( + (entry) => _decodeString( + entry.$2, + _indexPath('images.js:listReferencedAssetIds.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeInvitesCreateArgs({ + required double clientProtocolVersion, + ConvexOptional expiresAt = const ConvexOptional.absent(), + required InvitesCreateArgsRole role, + required String strategyPublicId, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:create.args.clientProtocolVersion', + ), + if (expiresAt.isPresent) + 'expiresAt': _encodeNumber( + expiresAt.value, + 'invites.js:create.args.expiresAt', + ), + 'role': ConvexString(role.wireName), + 'strategyPublicId': ConvexString(strategyPublicId), + 'token': ConvexString(token), +}); + +FoldersDeleteResult decodeInvitesCreateResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'invites.js:create.returns'); + +ConvexObject encodeInvitesGetArgs({ + ConvexOptional strategyPublicId = const ConvexOptional.absent(), + ConvexOptional token = const ConvexOptional.absent(), +}) => ConvexObject({ + if (strategyPublicId.isPresent) + 'strategyPublicId': ConvexString(strategyPublicId.value), + if (token.isPresent) 'token': ConvexString(token.value), +}); + +ConvexValue decodeInvitesGetResult(ConvexValue value) => + _decodeRaw(value, 'invites.js:get.returns', _validateInvitesGetResult); + +ConvexObject encodeInvitesRedeemArgs({ + required double clientProtocolVersion, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:redeem.args.clientProtocolVersion', + ), + 'token': ConvexString(token), +}); + +InvitesRedeemResult decodeInvitesRedeemResult(ConvexValue value) => + InvitesRedeemResult.decode(value, 'invites.js:redeem.returns'); + +ConvexObject encodeInvitesRevokeArgs({ + required double clientProtocolVersion, + required String strategyPublicId, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'invites.js:revoke.args.clientProtocolVersion', + ), + 'strategyPublicId': ConvexString(strategyPublicId), + 'token': ConvexString(token), +}); + +FoldersDeleteResult decodeInvitesRevokeResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'invites.js:revoke.returns'); + +ConvexObject encodeLineupsListForPageArgs({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'lineups.js:listForPage.args.clientProtocolVersion', + ), + 'pagePublicId': ConvexString(pagePublicId), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +List decodeLineupsListForPageResult( + ConvexValue value, +) => _decodeArray(value, 'lineups.js:listForPage.returns').value.indexed + .map( + (entry) => LineupsListForPageResultItem.decode( + entry.$2, + _indexPath('lineups.js:listForPage.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeLineupsListForStrategyArgs({ + required double clientProtocolVersion, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'lineups.js:listForStrategy.args.clientProtocolVersion', + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +List decodeLineupsListForStrategyResult( + ConvexValue value, +) => _decodeArray(value, 'lineups.js:listForStrategy.returns').value.indexed + .map( + (entry) => LineupsListForPageResultItem.decode( + entry.$2, + _indexPath('lineups.js:listForStrategy.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeOpsApplyBatchArgs({ + ConvexOptional accountSubject = const ConvexOptional.absent(), + ConvexOptional checkLineupEndDeletes = const ConvexOptional.absent(), + ConvexOptional checkLineupLinkEnds = const ConvexOptional.absent(), + ConvexOptional checkTrashedPageDeletes = const ConvexOptional.absent(), + required String clientId, + required double clientProtocolVersion, + required List ops, + required String strategyPublicId, +}) => ConvexObject({ + if (accountSubject.isPresent) + 'accountSubject': ConvexString(accountSubject.value), + if (checkLineupEndDeletes.isPresent) + 'checkLineupEndDeletes': ConvexBoolean(checkLineupEndDeletes.value), + if (checkLineupLinkEnds.isPresent) + 'checkLineupLinkEnds': ConvexBoolean(checkLineupLinkEnds.value), + if (checkTrashedPageDeletes.isPresent) + 'checkTrashedPageDeletes': ConvexBoolean(checkTrashedPageDeletes.value), + 'clientId': ConvexString(clientId), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'ops.js:applyBatch.args.clientProtocolVersion', + ), + 'ops': ConvexArray( + ops.indexed + .map( + (entry) => entry.$2.encode( + _indexPath('ops.js:applyBatch.args.ops', entry.$1), + ), + ) + .toList(growable: false), + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +OpsApplyBatchResult decodeOpsApplyBatchResult(ConvexValue value) => + OpsApplyBatchResult.decode(value, 'ops.js:applyBatch.returns'); + +ConvexObject encodePageGetSnapshotArgs({ + required double clientProtocolVersion, + required String pagePublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'page.js:getSnapshot.args.clientProtocolVersion', + ), + 'pagePublicId': ConvexString(pagePublicId), + if (shareToken.isPresent) 'shareToken': ConvexString(shareToken.value), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +PageGetSnapshotResult decodePageGetSnapshotResult(ConvexValue value) => + PageGetSnapshotResult.decode(value, 'page.js:getSnapshot.returns'); + +ConvexObject encodePagesAddArgs({ + required double clientProtocolVersion, + required double expectedRevision, + required bool isAttack, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + ConvexOptional settings = + const ConvexOptional.absent(), + required double sortIndex, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:add.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'pages.js:add.args.expectedRevision', + ), + 'isAttack': ConvexBoolean(isAttack), + if (isAutoNamed.isPresent) 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + 'name': ConvexString(name), + 'pagePublicId': ConvexString(pagePublicId), + if (settings.isPresent) + 'settings': settings.value.encode('pages.js:add.args.settings'), + 'sortIndex': _encodeNumber(sortIndex, 'pages.js:add.args.sortIndex'), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodePagesAddResult(ConvexValue value) => + _decodeRaw(value, 'pages.js:add.returns', _validatePagesAddResult); + +ConvexObject encodePagesDeleteArgs({ + required double clientProtocolVersion, + required double expectedRevision, + required String pagePublicId, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:delete.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'pages.js:delete.args.expectedRevision', + ), + 'pagePublicId': ConvexString(pagePublicId), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodePagesDeleteResult(ConvexValue value) => + _decodeRaw(value, 'pages.js:delete.returns', _validatePagesAddResult); + +ConvexObject encodePagesListForStrategyArgs({ + required String strategyPublicId, +}) => ConvexObject({'strategyPublicId': ConvexString(strategyPublicId)}); + +List decodePagesListForStrategyResult( + ConvexValue value, +) => _decodeArray(value, 'pages.js:listForStrategy.returns').value.indexed + .map( + (entry) => PageGetSnapshotResultPage.decode( + entry.$2, + _indexPath('pages.js:listForStrategy.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodePagesListTrashedArgs({required String strategyPublicId}) => + ConvexObject({'strategyPublicId': ConvexString(strategyPublicId)}); + +List decodePagesListTrashedResult( + ConvexValue value, +) => _decodeArray(value, 'pages.js:listTrashed.returns').value.indexed + .map( + (entry) => PagesListTrashedResultItem.decode( + entry.$2, + _indexPath('pages.js:listTrashed.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodePagesRenameArgs({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:rename.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'pages.js:rename.args.expectedRevision', + ), + if (isAutoNamed.isPresent) 'isAutoNamed': ConvexBoolean(isAutoNamed.value), + 'name': ConvexString(name), + 'pagePublicId': ConvexString(pagePublicId), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodePagesRenameResult(ConvexValue value) => + _decodeRaw(value, 'pages.js:rename.returns', _validatePagesAddResult); + +ConvexObject encodePagesReorderArgs({ + required double clientProtocolVersion, + required double expectedRevision, + required List orderedPagePublicIds, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:reorder.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'pages.js:reorder.args.expectedRevision', + ), + 'orderedPagePublicIds': ConvexArray( + orderedPagePublicIds.indexed + .map((entry) => ConvexString(entry.$2)) + .toList(growable: false), + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodePagesReorderResult(ConvexValue value) => + _decodeRaw(value, 'pages.js:reorder.returns', _validatePagesAddResult); + +ConvexObject encodePagesRestoreArgs({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'pages.js:restore.args.clientProtocolVersion', + ), + 'pagePublicId': ConvexString(pagePublicId), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodePagesRestoreResult(ConvexValue value) => + _decodeRaw(value, 'pages.js:restore.returns', _validatePagesAddResult); + +ConvexObject encodePresenceIssueRoomPassArgs({ + required double clientProtocolVersion, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'presence.js:issueRoomPass.args.clientProtocolVersion', + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +PresenceIssueRoomPassResult? decodePresenceIssueRoomPassResult( + ConvexValue value, +) => (value) is ConvexNull + ? null + : PresenceIssueRoomPassResult.decode( + value, + 'presence.js:issueRoomPass.returns', + ); + +ConvexObject encodeSharesCreateArgs({ + required double clientProtocolVersion, + required InvitesCreateArgsRole role, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:create.args.clientProtocolVersion', + ), + 'role': ConvexString(role.wireName), + 'targetPublicId': ConvexString(targetPublicId), + 'targetType': ConvexString(targetType.wireName), + 'token': ConvexString(token), +}); + +FoldersDeleteResult decodeSharesCreateResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'shares.js:create.returns'); + +ConvexObject encodeSharesListArgs({ + required String targetPublicId, + required SharesCreateArgsTargetType targetType, +}) => ConvexObject({ + 'targetPublicId': ConvexString(targetPublicId), + 'targetType': ConvexString(targetType.wireName), +}); + +List decodeSharesListResult(ConvexValue value) => + _decodeArray(value, 'shares.js:list.returns').value.indexed + .map( + (entry) => SharesListResultItem.decode( + entry.$2, + _indexPath('shares.js:list.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeSharesRedeemArgs({ + required double clientProtocolVersion, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:redeem.args.clientProtocolVersion', + ), + 'token': ConvexString(token), +}); + +SharesRedeemResult decodeSharesRedeemResult(ConvexValue value) => + SharesRedeemResult.decode(value, 'shares.js:redeem.returns'); + +ConvexObject encodeSharesResolveArgs({required String token}) => + ConvexObject({'token': ConvexString(token)}); + +SharesResolveResult decodeSharesResolveResult(ConvexValue value) => + SharesResolveResult.decode(value, 'shares.js:resolve.returns'); + +ConvexObject encodeSharesRevokeArgs({ + required double clientProtocolVersion, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'shares.js:revoke.args.clientProtocolVersion', + ), + 'targetPublicId': ConvexString(targetPublicId), + 'targetType': ConvexString(targetType.wireName), + 'token': ConvexString(token), +}); + +FoldersDeleteResult decodeSharesRevokeResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'shares.js:revoke.returns'); + +ConvexObject encodeStrategiesCreateArgs({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:create.args.clientProtocolVersion', + ), + if (folderPublicId.isPresent) + 'folderPublicId': ConvexString(folderPublicId.value), + 'mapData': ConvexString(mapData), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + if (themeOverridePalette.isPresent) + 'themeOverridePalette': themeOverridePalette.value.encode( + 'strategies.js:create.args.themeOverridePalette', + ), + if (themeProfileId.isPresent) + 'themeProfileId': ConvexString(themeProfileId.value), +}); + +ConvexValue decodeStrategiesCreateResult(ConvexValue value) => _decodeRaw( + value, + 'strategies.js:create.returns', + _validateFoldersCreateResult, +); + +ConvexObject encodeStrategiesCreateWithInitialPageArgs({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required bool initialPageIsAttack, + ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), + required String initialPageName, + required String initialPagePublicId, + ConvexOptional + initialPageSettings = + const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:createWithInitialPage.args.clientProtocolVersion', + ), + if (folderPublicId.isPresent) + 'folderPublicId': ConvexString(folderPublicId.value), + 'initialPageIsAttack': ConvexBoolean(initialPageIsAttack), + if (initialPageIsAutoNamed.isPresent) + 'initialPageIsAutoNamed': ConvexBoolean(initialPageIsAutoNamed.value), + 'initialPageName': ConvexString(initialPageName), + 'initialPagePublicId': ConvexString(initialPagePublicId), + if (initialPageSettings.isPresent) + 'initialPageSettings': initialPageSettings.value.encode( + 'strategies.js:createWithInitialPage.args.initialPageSettings', + ), + 'mapData': ConvexString(mapData), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + if (themeOverridePalette.isPresent) + 'themeOverridePalette': themeOverridePalette.value.encode( + 'strategies.js:createWithInitialPage.args.themeOverridePalette', + ), + if (themeProfileId.isPresent) + 'themeProfileId': ConvexString(themeProfileId.value), +}); + +ConvexValue decodeStrategiesCreateWithInitialPageResult(ConvexValue value) => + _decodeRaw( + value, + 'strategies.js:createWithInitialPage.returns', + _validateFoldersCreateResult, + ); + +ConvexObject encodeStrategiesDeleteArgs({ + required double clientProtocolVersion, + required double expectedRevision, + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:delete.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'strategies.js:delete.args.expectedRevision', + ), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +FoldersDeleteResult decodeStrategiesDeleteResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'strategies.js:delete.returns'); + +ConvexObject encodeStrategiesDuplicateArgs({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String name, + required String publicId, + required String sourceStrategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:duplicate.args.clientProtocolVersion', + ), + if (folderPublicId.isPresent) + 'folderPublicId': ConvexString(folderPublicId.value), + 'name': ConvexString(name), + 'publicId': ConvexString(publicId), + 'sourceStrategyPublicId': ConvexString(sourceStrategyPublicId), +}); + +ConvexValue decodeStrategiesDuplicateResult(ConvexValue value) => _decodeRaw( + value, + 'strategies.js:duplicate.returns', + _validateFoldersCreateResult, +); + +ConvexObject encodeStrategiesGetHeaderArgs({ + required String strategyPublicId, +}) => ConvexObject({'strategyPublicId': ConvexString(strategyPublicId)}); + +StrategiesGetHeaderResult decodeStrategiesGetHeaderResult(ConvexValue value) => + StrategiesGetHeaderResult.decode(value, 'strategies.js:getHeader.returns'); + +ConvexObject encodeStrategiesListForFolderArgs({ + ConvexOptional folderPublicId = const ConvexOptional.absent(), + ConvexOptional scope = + const ConvexOptional.absent(), +}) => ConvexObject({ + if (folderPublicId.isPresent) + 'folderPublicId': ConvexString(folderPublicId.value), + if (scope.isPresent) 'scope': ConvexString(scope.value.wireName), +}); + +List decodeStrategiesListForFolderResult( + ConvexValue value, +) => _decodeArray(value, 'strategies.js:listForFolder.returns').value.indexed + .map( + (entry) => StrategiesListForFolderResultItem.decode( + entry.$2, + _indexPath('strategies.js:listForFolder.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeStrategiesListSharedWithMeArgs() => ConvexObject({}); + +List decodeStrategiesListSharedWithMeResult( + ConvexValue value, +) => _decodeArray(value, 'strategies.js:listSharedWithMe.returns').value.indexed + .map( + (entry) => StrategiesListForFolderResultItem.decode( + entry.$2, + _indexPath('strategies.js:listSharedWithMe.returns', entry.$1), + ), + ) + .toList(growable: false); + +ConvexObject encodeStrategiesMoveArgs({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:move.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'strategies.js:move.args.expectedRevision', + ), + if (folderPublicId.isPresent) + 'folderPublicId': ConvexString(folderPublicId.value), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +ConvexValue decodeStrategiesMoveResult(ConvexValue value) => + _decodeRaw(value, 'strategies.js:move.returns', _validatePagesAddResult); + +ConvexObject encodeStrategiesUpdateArgs({ + ConvexOptional clearThemeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional mapData = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), +}) => ConvexObject({ + if (clearThemeOverridePalette.isPresent) + 'clearThemeOverridePalette': ConvexBoolean(clearThemeOverridePalette.value), + if (clearThemeProfileId.isPresent) + 'clearThemeProfileId': ConvexBoolean(clearThemeProfileId.value), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategies.js:update.args.clientProtocolVersion', + ), + 'expectedRevision': _encodeNumber( + expectedRevision, + 'strategies.js:update.args.expectedRevision', + ), + if (mapData.isPresent) 'mapData': ConvexString(mapData.value), + if (name.isPresent) 'name': ConvexString(name.value), + 'strategyPublicId': ConvexString(strategyPublicId), + if (themeOverridePalette.isPresent) + 'themeOverridePalette': themeOverridePalette.value.encode( + 'strategies.js:update.args.themeOverridePalette', + ), + if (themeProfileId.isPresent) + 'themeProfileId': ConvexString(themeProfileId.value), +}); + +ConvexValue decodeStrategiesUpdateResult(ConvexValue value) => + _decodeRaw(value, 'strategies.js:update.returns', _validatePagesAddResult); + +ConvexObject encodeStrategyGetFullSnapshotArgs({ + ConvexOptional acceptsTrashedPagesLeftOut = + const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, +}) => ConvexObject({ + if (acceptsTrashedPagesLeftOut.isPresent) + 'acceptsTrashedPagesLeftOut': ConvexBoolean( + acceptsTrashedPagesLeftOut.value, + ), + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategy.js:getFullSnapshot.args.clientProtocolVersion', + ), + if (shareToken.isPresent) 'shareToken': ConvexString(shareToken.value), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +StrategyGetFullSnapshotResult decodeStrategyGetFullSnapshotResult( + ConvexValue value, +) => StrategyGetFullSnapshotResult.decode( + value, + 'strategy.js:getFullSnapshot.returns', +); + +ConvexObject encodeStrategyGetShellArgs({ + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'strategy.js:getShell.args.clientProtocolVersion', + ), + if (shareToken.isPresent) 'shareToken': ConvexString(shareToken.value), + 'strategyPublicId': ConvexString(strategyPublicId), +}); + +StrategyGetShellResult decodeStrategyGetShellResult(ConvexValue value) => + StrategyGetShellResult.decode(value, 'strategy.js:getShell.returns'); + +ConvexObject encodeUsersEnsureCurrentUserArgs({ + required double clientProtocolVersion, +}) => ConvexObject({ + 'clientProtocolVersion': _encodeNumber( + clientProtocolVersion, + 'users.js:ensureCurrentUser.args.clientProtocolVersion', + ), +}); + +FoldersDeleteResult decodeUsersEnsureCurrentUserResult(ConvexValue value) => + FoldersDeleteResult.decode(value, 'users.js:ensureCurrentUser.returns'); + +ConvexObject encodeUsersMeArgs() => ConvexObject({}); + +UsersMeResult? decodeUsersMeResult(ConvexValue value) => (value) is ConvexNull + ? null + : UsersMeResult.decode(value, 'users.js:me.returns'); diff --git a/lib/collab/generated/generated.dart b/lib/collab/generated/generated.dart new file mode 100644 index 00000000..247b303f --- /dev/null +++ b/lib/collab/generated/generated.dart @@ -0,0 +1,7 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from convex/function_spec.json by tool/icarus_convex_codegen. +// ignore_for_file: prefer_const_constructors, unused_element, unused_import + +export 'convex_error_codes.dart'; +export 'convex_models.dart'; +export 'icarus_convex_api.dart'; diff --git a/lib/collab/generated/icarus_convex_api.dart b/lib/collab/generated/icarus_convex_api.dart new file mode 100644 index 00000000..a4facdd0 --- /dev/null +++ b/lib/collab/generated/icarus_convex_api.dart @@ -0,0 +1,1511 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from convex/function_spec.json by tool/icarus_convex_codegen. +// ignore_for_file: prefer_const_constructors, unused_element, unused_import + +import 'dart:async'; +import 'dart:typed_data'; + +import '../transport/convex_transport.dart'; +import 'convex_error_codes.dart'; +import 'convex_models.dart'; + +final class ConvexQuery { + const ConvexQuery({ + required ConvexTransport transport, + required String name, + required ConvexObject args, + required T Function(ConvexValue) decode, + }) : _transport = transport, + _name = name, + _args = args, + _decode = decode; + + final ConvexTransport _transport; + final String _name; + final ConvexObject _args; + final T Function(ConvexValue) _decode; + + Future fetch() => _invoke(() => _transport.query(_name, _args), _decode); + + Stream watch() { + late final StreamController controller; + StreamSubscription? subscription; + var active = false; + + controller = StreamController( + onListen: () { + active = true; + subscription = _transport + .subscribe(_name, _args) + .listen( + (value) { + if (!active) return; + try { + controller.add(_decode(value)); + } catch (error, stackTrace) { + active = false; + controller.addError(error, stackTrace); + subscription?.cancel(); + controller.close(); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!active) return; + if (error is ConvexTransportError) { + controller.addError( + ConvexFunctionException.fromTransport(error), + stackTrace, + ); + return; + } + active = false; + controller.addError(error, stackTrace); + subscription?.cancel(); + controller.close(); + }, + onDone: () { + if (!active) return; + active = false; + controller.close(); + }, + ); + }, + onCancel: () { + active = false; + return subscription?.cancel(); + }, + ); + return controller.stream; + } +} + +Future _invoke( + Future Function() invoke, + T Function(ConvexValue) decode, +) async { + try { + return decode(await invoke()); + } on ConvexTransportError catch (error) { + throw ConvexFunctionException.fromTransport(error); + } +} + +abstract interface class IcarusConvexApi { + factory IcarusConvexApi(ConvexTransport transport) = _IcarusConvexApi; + ElementsModule get elements; + FoldersModule get folders; + HealthModule get health; + ImagesModule get images; + InvitesModule get invites; + LineupsModule get lineups; + OpsModule get ops; + PageModule get page; + PagesModule get pages; + PresenceModule get presence; + SharesModule get shares; + StrategiesModule get strategies; + StrategyModule get strategy; + UsersModule get users; +} + +final class _IcarusConvexApi implements IcarusConvexApi { + _IcarusConvexApi(ConvexTransport transport) + : elements = _ElementsModule(transport), + folders = _FoldersModule(transport), + health = _HealthModule(transport), + images = _ImagesModule(transport), + invites = _InvitesModule(transport), + lineups = _LineupsModule(transport), + ops = _OpsModule(transport), + page = _PageModule(transport), + pages = _PagesModule(transport), + presence = _PresenceModule(transport), + shares = _SharesModule(transport), + strategies = _StrategiesModule(transport), + strategy = _StrategyModule(transport), + users = _UsersModule(transport); + @override + final ElementsModule elements; + @override + final FoldersModule folders; + @override + final HealthModule health; + @override + final ImagesModule images; + @override + final InvitesModule invites; + @override + final LineupsModule lineups; + @override + final OpsModule ops; + @override + final PageModule page; + @override + final PagesModule pages; + @override + final PresenceModule presence; + @override + final SharesModule shares; + @override + final StrategiesModule strategies; + @override + final StrategyModule strategy; + @override + final UsersModule users; +} + +abstract interface class ElementsModule { + ConvexQuery> listForPage({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }); + ConvexQuery> listForStrategy({ + required double clientProtocolVersion, + required String strategyPublicId, + }); +} + +final class _ElementsModule implements ElementsModule { + const _ElementsModule(this._transport); + final ConvexTransport _transport; + @override + ConvexQuery> listForPage({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }) { + final args = encodeElementsListForPageArgs( + clientProtocolVersion: clientProtocolVersion, + pagePublicId: pagePublicId, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'elements:listForPage', + args: args, + decode: decodeElementsListForPageResult, + ); + } + + @override + ConvexQuery> listForStrategy({ + required double clientProtocolVersion, + required String strategyPublicId, + }) { + final args = encodeElementsListForStrategyArgs( + clientProtocolVersion: clientProtocolVersion, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'elements:listForStrategy', + args: args, + decode: decodeElementsListForStrategyResult, + ); + } +} + +abstract interface class FoldersModule { + Future create({ + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + required String name, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), + required String publicId, + }); + Future delete({ + required double clientProtocolVersion, + required String folderPublicId, + }); + ConvexQuery> listTree({ + ConvexOptional scope = + const ConvexOptional.absent(), + }); + Future move({ + required double clientProtocolVersion, + required String folderPublicId, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), + }); + Future update({ + ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), + ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), + ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + required String folderPublicId, + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), + }); +} + +final class _FoldersModule implements FoldersModule { + const _FoldersModule(this._transport); + final ConvexTransport _transport; + @override + Future create({ + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + required String name, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), + required String publicId, + }) { + final args = encodeFoldersCreateArgs( + clientProtocolVersion: clientProtocolVersion, + color: color, + customColorValue: customColorValue, + iconCodePoint: iconCodePoint, + iconFontFamily: iconFontFamily, + iconFontPackage: iconFontPackage, + iconId: iconId, + name: name, + parentFolderPublicId: parentFolderPublicId, + publicId: publicId, + ); + return _invoke( + () => _transport.mutation('folders:create', args), + decodeFoldersCreateResult, + ); + } + + @override + Future delete({ + required double clientProtocolVersion, + required String folderPublicId, + }) { + final args = encodeFoldersDeleteArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + ); + return _invoke( + () => _transport.mutation('folders:delete', args), + decodeFoldersDeleteResult, + ); + } + + @override + ConvexQuery> listTree({ + ConvexOptional scope = + const ConvexOptional.absent(), + }) { + final args = encodeFoldersListTreeArgs(scope: scope); + return ConvexQuery( + transport: _transport, + name: 'folders:listTree', + args: args, + decode: decodeFoldersListTreeResult, + ); + } + + @override + Future move({ + required double clientProtocolVersion, + required String folderPublicId, + ConvexOptional parentFolderPublicId = const ConvexOptional.absent(), + }) { + final args = encodeFoldersMoveArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + parentFolderPublicId: parentFolderPublicId, + ); + return _invoke( + () => _transport.mutation('folders:move', args), + decodeFoldersMoveResult, + ); + } + + @override + Future update({ + ConvexOptional clearCustomColorValue = const ConvexOptional.absent(), + ConvexOptional clearIconFontFamily = const ConvexOptional.absent(), + ConvexOptional clearIconFontPackage = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional color = const ConvexOptional.absent(), + ConvexOptional customColorValue = const ConvexOptional.absent(), + required String folderPublicId, + ConvexOptional iconCodePoint = const ConvexOptional.absent(), + ConvexOptional iconFontFamily = const ConvexOptional.absent(), + ConvexOptional iconFontPackage = const ConvexOptional.absent(), + ConvexOptional iconId = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), + }) { + final args = encodeFoldersUpdateArgs( + clearCustomColorValue: clearCustomColorValue, + clearIconFontFamily: clearIconFontFamily, + clearIconFontPackage: clearIconFontPackage, + clientProtocolVersion: clientProtocolVersion, + color: color, + customColorValue: customColorValue, + folderPublicId: folderPublicId, + iconCodePoint: iconCodePoint, + iconFontFamily: iconFontFamily, + iconFontPackage: iconFontPackage, + iconId: iconId, + name: name, + ); + return _invoke( + () => _transport.mutation('folders:update', args), + decodeFoldersUpdateResult, + ); + } +} + +abstract interface class HealthModule { + ConvexQuery ping({ + ConvexOptional clientProtocolVersion = + const ConvexOptional.absent(), + }); +} + +final class _HealthModule implements HealthModule { + const _HealthModule(this._transport); + final ConvexTransport _transport; + @override + ConvexQuery ping({ + ConvexOptional clientProtocolVersion = + const ConvexOptional.absent(), + }) { + final args = encodeHealthPingArgs( + clientProtocolVersion: clientProtocolVersion, + ); + return ConvexQuery( + transport: _transport, + name: 'health:ping', + args: args, + decode: decodeHealthPingResult, + ); + } +} + +abstract interface class ImagesModule { + Future completeUpload({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional etag = const ConvexOptional.absent(), + ConvexOptional fileExtension = const ConvexOptional.absent(), + ConvexOptional height = const ConvexOptional.absent(), + ConvexOptional mimeType = const ConvexOptional.absent(), + ConvexOptional objectKey = const ConvexOptional.absent(), + ConvexOptional provider = + const ConvexOptional.absent(), + ConvexOptional storageId = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional uploadId = const ConvexOptional.absent(), + ConvexOptional width = const ConvexOptional.absent(), + }); + Future deleteAssetRef({ + required String assetPublicId, + required double clientProtocolVersion, + required String strategyPublicId, + }); + Future generateUploadUrl({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + required String fileExtension, + ConvexOptional height = const ConvexOptional.absent(), + required String mimeType, + required String strategyPublicId, + ConvexOptional width = const ConvexOptional.absent(), + }); + ConvexQuery getAssetUrl({ + required String assetPublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }); + ConvexQuery> listForStrategy({ + required String strategyPublicId, + }); + ConvexQuery?> listReferencedAssetIds({ + required List assetPublicIds, + required String strategyPublicId, + }); +} + +final class _ImagesModule implements ImagesModule { + const _ImagesModule(this._transport); + final ConvexTransport _transport; + @override + Future completeUpload({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional etag = const ConvexOptional.absent(), + ConvexOptional fileExtension = const ConvexOptional.absent(), + ConvexOptional height = const ConvexOptional.absent(), + ConvexOptional mimeType = const ConvexOptional.absent(), + ConvexOptional objectKey = const ConvexOptional.absent(), + ConvexOptional provider = + const ConvexOptional.absent(), + ConvexOptional storageId = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional uploadId = const ConvexOptional.absent(), + ConvexOptional width = const ConvexOptional.absent(), + }) { + final args = encodeImagesCompleteUploadArgs( + assetPublicId: assetPublicId, + byteSize: byteSize, + clientProtocolVersion: clientProtocolVersion, + etag: etag, + fileExtension: fileExtension, + height: height, + mimeType: mimeType, + objectKey: objectKey, + provider: provider, + storageId: storageId, + strategyPublicId: strategyPublicId, + uploadId: uploadId, + width: width, + ); + return _invoke( + () => _transport.action('images:completeUpload', args), + decodeImagesCompleteUploadResult, + ); + } + + @override + Future deleteAssetRef({ + required String assetPublicId, + required double clientProtocolVersion, + required String strategyPublicId, + }) { + final args = encodeImagesDeleteAssetRefArgs( + assetPublicId: assetPublicId, + clientProtocolVersion: clientProtocolVersion, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.action('images:deleteAssetRef', args), + decodeImagesDeleteAssetRefResult, + ); + } + + @override + Future generateUploadUrl({ + required String assetPublicId, + ConvexOptional byteSize = const ConvexOptional.absent(), + required double clientProtocolVersion, + required String fileExtension, + ConvexOptional height = const ConvexOptional.absent(), + required String mimeType, + required String strategyPublicId, + ConvexOptional width = const ConvexOptional.absent(), + }) { + final args = encodeImagesGenerateUploadUrlArgs( + assetPublicId: assetPublicId, + byteSize: byteSize, + clientProtocolVersion: clientProtocolVersion, + fileExtension: fileExtension, + height: height, + mimeType: mimeType, + strategyPublicId: strategyPublicId, + width: width, + ); + return _invoke( + () => _transport.action('images:generateUploadUrl', args), + decodeImagesGenerateUploadUrlResult, + ); + } + + @override + ConvexQuery getAssetUrl({ + required String assetPublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }) { + final args = encodeImagesGetAssetUrlArgs( + assetPublicId: assetPublicId, + shareToken: shareToken, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'images:getAssetUrl', + args: args, + decode: decodeImagesGetAssetUrlResult, + ); + } + + @override + ConvexQuery> listForStrategy({ + required String strategyPublicId, + }) { + final args = encodeImagesListForStrategyArgs( + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'images:listForStrategy', + args: args, + decode: decodeImagesListForStrategyResult, + ); + } + + @override + ConvexQuery?> listReferencedAssetIds({ + required List assetPublicIds, + required String strategyPublicId, + }) { + final args = encodeImagesListReferencedAssetIdsArgs( + assetPublicIds: assetPublicIds, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'images:listReferencedAssetIds', + args: args, + decode: decodeImagesListReferencedAssetIdsResult, + ); + } +} + +abstract interface class InvitesModule { + Future create({ + required double clientProtocolVersion, + ConvexOptional expiresAt = const ConvexOptional.absent(), + required InvitesCreateArgsRole role, + required String strategyPublicId, + required String token, + }); + ConvexQuery getValue({ + ConvexOptional strategyPublicId = const ConvexOptional.absent(), + ConvexOptional token = const ConvexOptional.absent(), + }); + Future redeem({ + required double clientProtocolVersion, + required String token, + }); + Future revoke({ + required double clientProtocolVersion, + required String strategyPublicId, + required String token, + }); +} + +final class _InvitesModule implements InvitesModule { + const _InvitesModule(this._transport); + final ConvexTransport _transport; + @override + Future create({ + required double clientProtocolVersion, + ConvexOptional expiresAt = const ConvexOptional.absent(), + required InvitesCreateArgsRole role, + required String strategyPublicId, + required String token, + }) { + final args = encodeInvitesCreateArgs( + clientProtocolVersion: clientProtocolVersion, + expiresAt: expiresAt, + role: role, + strategyPublicId: strategyPublicId, + token: token, + ); + return _invoke( + () => _transport.mutation('invites:create', args), + decodeInvitesCreateResult, + ); + } + + @override + ConvexQuery getValue({ + ConvexOptional strategyPublicId = const ConvexOptional.absent(), + ConvexOptional token = const ConvexOptional.absent(), + }) { + final args = encodeInvitesGetArgs( + strategyPublicId: strategyPublicId, + token: token, + ); + return ConvexQuery( + transport: _transport, + name: 'invites:get', + args: args, + decode: decodeInvitesGetResult, + ); + } + + @override + Future redeem({ + required double clientProtocolVersion, + required String token, + }) { + final args = encodeInvitesRedeemArgs( + clientProtocolVersion: clientProtocolVersion, + token: token, + ); + return _invoke( + () => _transport.mutation('invites:redeem', args), + decodeInvitesRedeemResult, + ); + } + + @override + Future revoke({ + required double clientProtocolVersion, + required String strategyPublicId, + required String token, + }) { + final args = encodeInvitesRevokeArgs( + clientProtocolVersion: clientProtocolVersion, + strategyPublicId: strategyPublicId, + token: token, + ); + return _invoke( + () => _transport.mutation('invites:revoke', args), + decodeInvitesRevokeResult, + ); + } +} + +abstract interface class LineupsModule { + ConvexQuery> listForPage({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }); + ConvexQuery> listForStrategy({ + required double clientProtocolVersion, + required String strategyPublicId, + }); +} + +final class _LineupsModule implements LineupsModule { + const _LineupsModule(this._transport); + final ConvexTransport _transport; + @override + ConvexQuery> listForPage({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }) { + final args = encodeLineupsListForPageArgs( + clientProtocolVersion: clientProtocolVersion, + pagePublicId: pagePublicId, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'lineups:listForPage', + args: args, + decode: decodeLineupsListForPageResult, + ); + } + + @override + ConvexQuery> listForStrategy({ + required double clientProtocolVersion, + required String strategyPublicId, + }) { + final args = encodeLineupsListForStrategyArgs( + clientProtocolVersion: clientProtocolVersion, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'lineups:listForStrategy', + args: args, + decode: decodeLineupsListForStrategyResult, + ); + } +} + +abstract interface class OpsModule { + Future applyBatch({ + ConvexOptional accountSubject = const ConvexOptional.absent(), + ConvexOptional checkLineupEndDeletes = const ConvexOptional.absent(), + ConvexOptional checkLineupLinkEnds = const ConvexOptional.absent(), + ConvexOptional checkTrashedPageDeletes = + const ConvexOptional.absent(), + required String clientId, + required double clientProtocolVersion, + required List ops, + required String strategyPublicId, + }); +} + +final class _OpsModule implements OpsModule { + const _OpsModule(this._transport); + final ConvexTransport _transport; + @override + Future applyBatch({ + ConvexOptional accountSubject = const ConvexOptional.absent(), + ConvexOptional checkLineupEndDeletes = const ConvexOptional.absent(), + ConvexOptional checkLineupLinkEnds = const ConvexOptional.absent(), + ConvexOptional checkTrashedPageDeletes = + const ConvexOptional.absent(), + required String clientId, + required double clientProtocolVersion, + required List ops, + required String strategyPublicId, + }) { + final args = encodeOpsApplyBatchArgs( + accountSubject: accountSubject, + checkLineupEndDeletes: checkLineupEndDeletes, + checkLineupLinkEnds: checkLineupLinkEnds, + checkTrashedPageDeletes: checkTrashedPageDeletes, + clientId: clientId, + clientProtocolVersion: clientProtocolVersion, + ops: ops, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('ops:applyBatch', args), + decodeOpsApplyBatchResult, + ); + } +} + +abstract interface class PageModule { + ConvexQuery getSnapshot({ + required double clientProtocolVersion, + required String pagePublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }); +} + +final class _PageModule implements PageModule { + const _PageModule(this._transport); + final ConvexTransport _transport; + @override + ConvexQuery getSnapshot({ + required double clientProtocolVersion, + required String pagePublicId, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }) { + final args = encodePageGetSnapshotArgs( + clientProtocolVersion: clientProtocolVersion, + pagePublicId: pagePublicId, + shareToken: shareToken, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'page:getSnapshot', + args: args, + decode: decodePageGetSnapshotResult, + ); + } +} + +abstract interface class PagesModule { + Future add({ + required double clientProtocolVersion, + required double expectedRevision, + required bool isAttack, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + ConvexOptional settings = + const ConvexOptional.absent(), + required double sortIndex, + required String strategyPublicId, + }); + Future delete({ + required double clientProtocolVersion, + required double expectedRevision, + required String pagePublicId, + required String strategyPublicId, + }); + ConvexQuery> listForStrategy({ + required String strategyPublicId, + }); + ConvexQuery> listTrashed({ + required String strategyPublicId, + }); + Future rename({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + required String strategyPublicId, + }); + Future reorder({ + required double clientProtocolVersion, + required double expectedRevision, + required List orderedPagePublicIds, + required String strategyPublicId, + }); + Future restore({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }); +} + +final class _PagesModule implements PagesModule { + const _PagesModule(this._transport); + final ConvexTransport _transport; + @override + Future add({ + required double clientProtocolVersion, + required double expectedRevision, + required bool isAttack, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + ConvexOptional settings = + const ConvexOptional.absent(), + required double sortIndex, + required String strategyPublicId, + }) { + final args = encodePagesAddArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + isAttack: isAttack, + isAutoNamed: isAutoNamed, + name: name, + pagePublicId: pagePublicId, + settings: settings, + sortIndex: sortIndex, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('pages:add', args), + decodePagesAddResult, + ); + } + + @override + Future delete({ + required double clientProtocolVersion, + required double expectedRevision, + required String pagePublicId, + required String strategyPublicId, + }) { + final args = encodePagesDeleteArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + pagePublicId: pagePublicId, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('pages:delete', args), + decodePagesDeleteResult, + ); + } + + @override + ConvexQuery> listForStrategy({ + required String strategyPublicId, + }) { + final args = encodePagesListForStrategyArgs( + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'pages:listForStrategy', + args: args, + decode: decodePagesListForStrategyResult, + ); + } + + @override + ConvexQuery> listTrashed({ + required String strategyPublicId, + }) { + final args = encodePagesListTrashedArgs(strategyPublicId: strategyPublicId); + return ConvexQuery( + transport: _transport, + name: 'pages:listTrashed', + args: args, + decode: decodePagesListTrashedResult, + ); + } + + @override + Future rename({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional isAutoNamed = const ConvexOptional.absent(), + required String name, + required String pagePublicId, + required String strategyPublicId, + }) { + final args = encodePagesRenameArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + isAutoNamed: isAutoNamed, + name: name, + pagePublicId: pagePublicId, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('pages:rename', args), + decodePagesRenameResult, + ); + } + + @override + Future reorder({ + required double clientProtocolVersion, + required double expectedRevision, + required List orderedPagePublicIds, + required String strategyPublicId, + }) { + final args = encodePagesReorderArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + orderedPagePublicIds: orderedPagePublicIds, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('pages:reorder', args), + decodePagesReorderResult, + ); + } + + @override + Future restore({ + required double clientProtocolVersion, + required String pagePublicId, + required String strategyPublicId, + }) { + final args = encodePagesRestoreArgs( + clientProtocolVersion: clientProtocolVersion, + pagePublicId: pagePublicId, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('pages:restore', args), + decodePagesRestoreResult, + ); + } +} + +abstract interface class PresenceModule { + Future issueRoomPass({ + required double clientProtocolVersion, + required String strategyPublicId, + }); +} + +final class _PresenceModule implements PresenceModule { + const _PresenceModule(this._transport); + final ConvexTransport _transport; + @override + Future issueRoomPass({ + required double clientProtocolVersion, + required String strategyPublicId, + }) { + final args = encodePresenceIssueRoomPassArgs( + clientProtocolVersion: clientProtocolVersion, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('presence:issueRoomPass', args), + decodePresenceIssueRoomPassResult, + ); + } +} + +abstract interface class SharesModule { + Future create({ + required double clientProtocolVersion, + required InvitesCreateArgsRole role, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, + }); + ConvexQuery> list({ + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + }); + Future redeem({ + required double clientProtocolVersion, + required String token, + }); + ConvexQuery resolve({required String token}); + Future revoke({ + required double clientProtocolVersion, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, + }); +} + +final class _SharesModule implements SharesModule { + const _SharesModule(this._transport); + final ConvexTransport _transport; + @override + Future create({ + required double clientProtocolVersion, + required InvitesCreateArgsRole role, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, + }) { + final args = encodeSharesCreateArgs( + clientProtocolVersion: clientProtocolVersion, + role: role, + targetPublicId: targetPublicId, + targetType: targetType, + token: token, + ); + return _invoke( + () => _transport.mutation('shares:create', args), + decodeSharesCreateResult, + ); + } + + @override + ConvexQuery> list({ + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + }) { + final args = encodeSharesListArgs( + targetPublicId: targetPublicId, + targetType: targetType, + ); + return ConvexQuery( + transport: _transport, + name: 'shares:list', + args: args, + decode: decodeSharesListResult, + ); + } + + @override + Future redeem({ + required double clientProtocolVersion, + required String token, + }) { + final args = encodeSharesRedeemArgs( + clientProtocolVersion: clientProtocolVersion, + token: token, + ); + return _invoke( + () => _transport.mutation('shares:redeem', args), + decodeSharesRedeemResult, + ); + } + + @override + ConvexQuery resolve({required String token}) { + final args = encodeSharesResolveArgs(token: token); + return ConvexQuery( + transport: _transport, + name: 'shares:resolve', + args: args, + decode: decodeSharesResolveResult, + ); + } + + @override + Future revoke({ + required double clientProtocolVersion, + required String targetPublicId, + required SharesCreateArgsTargetType targetType, + required String token, + }) { + final args = encodeSharesRevokeArgs( + clientProtocolVersion: clientProtocolVersion, + targetPublicId: targetPublicId, + targetType: targetType, + token: token, + ); + return _invoke( + () => _transport.mutation('shares:revoke', args), + decodeSharesRevokeResult, + ); + } +} + +abstract interface class StrategiesModule { + Future create({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }); + Future createWithInitialPage({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required bool initialPageIsAttack, + ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), + required String initialPageName, + required String initialPagePublicId, + ConvexOptional + initialPageSettings = + const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }); + Future delete({ + required double clientProtocolVersion, + required double expectedRevision, + required String strategyPublicId, + }); + Future duplicate({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String name, + required String publicId, + required String sourceStrategyPublicId, + }); + ConvexQuery getHeader({ + required String strategyPublicId, + }); + ConvexQuery> listForFolder({ + ConvexOptional folderPublicId = const ConvexOptional.absent(), + ConvexOptional scope = + const ConvexOptional.absent(), + }); + ConvexQuery> listSharedWithMe(); + Future move({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String strategyPublicId, + }); + Future update({ + ConvexOptional clearThemeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional mapData = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }); +} + +final class _StrategiesModule implements StrategiesModule { + const _StrategiesModule(this._transport); + final ConvexTransport _transport; + @override + Future create({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }) { + final args = encodeStrategiesCreateArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + mapData: mapData, + name: name, + publicId: publicId, + themeOverridePalette: themeOverridePalette, + themeProfileId: themeProfileId, + ); + return _invoke( + () => _transport.mutation('strategies:create', args), + decodeStrategiesCreateResult, + ); + } + + @override + Future createWithInitialPage({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required bool initialPageIsAttack, + ConvexOptional initialPageIsAutoNamed = const ConvexOptional.absent(), + required String initialPageName, + required String initialPagePublicId, + ConvexOptional + initialPageSettings = + const ConvexOptional.absent(), + required String mapData, + required String name, + required String publicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }) { + final args = encodeStrategiesCreateWithInitialPageArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + initialPageIsAttack: initialPageIsAttack, + initialPageIsAutoNamed: initialPageIsAutoNamed, + initialPageName: initialPageName, + initialPagePublicId: initialPagePublicId, + initialPageSettings: initialPageSettings, + mapData: mapData, + name: name, + publicId: publicId, + themeOverridePalette: themeOverridePalette, + themeProfileId: themeProfileId, + ); + return _invoke( + () => _transport.mutation('strategies:createWithInitialPage', args), + decodeStrategiesCreateWithInitialPageResult, + ); + } + + @override + Future delete({ + required double clientProtocolVersion, + required double expectedRevision, + required String strategyPublicId, + }) { + final args = encodeStrategiesDeleteArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('strategies:delete', args), + decodeStrategiesDeleteResult, + ); + } + + @override + Future duplicate({ + required double clientProtocolVersion, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String name, + required String publicId, + required String sourceStrategyPublicId, + }) { + final args = encodeStrategiesDuplicateArgs( + clientProtocolVersion: clientProtocolVersion, + folderPublicId: folderPublicId, + name: name, + publicId: publicId, + sourceStrategyPublicId: sourceStrategyPublicId, + ); + return _invoke( + () => _transport.mutation('strategies:duplicate', args), + decodeStrategiesDuplicateResult, + ); + } + + @override + ConvexQuery getHeader({ + required String strategyPublicId, + }) { + final args = encodeStrategiesGetHeaderArgs( + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'strategies:getHeader', + args: args, + decode: decodeStrategiesGetHeaderResult, + ); + } + + @override + ConvexQuery> listForFolder({ + ConvexOptional folderPublicId = const ConvexOptional.absent(), + ConvexOptional scope = + const ConvexOptional.absent(), + }) { + final args = encodeStrategiesListForFolderArgs( + folderPublicId: folderPublicId, + scope: scope, + ); + return ConvexQuery( + transport: _transport, + name: 'strategies:listForFolder', + args: args, + decode: decodeStrategiesListForFolderResult, + ); + } + + @override + ConvexQuery> listSharedWithMe() { + final args = encodeStrategiesListSharedWithMeArgs(); + return ConvexQuery( + transport: _transport, + name: 'strategies:listSharedWithMe', + args: args, + decode: decodeStrategiesListSharedWithMeResult, + ); + } + + @override + Future move({ + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional folderPublicId = const ConvexOptional.absent(), + required String strategyPublicId, + }) { + final args = encodeStrategiesMoveArgs( + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + folderPublicId: folderPublicId, + strategyPublicId: strategyPublicId, + ); + return _invoke( + () => _transport.mutation('strategies:move', args), + decodeStrategiesMoveResult, + ); + } + + @override + Future update({ + ConvexOptional clearThemeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional clearThemeProfileId = const ConvexOptional.absent(), + required double clientProtocolVersion, + required double expectedRevision, + ConvexOptional mapData = const ConvexOptional.absent(), + ConvexOptional name = const ConvexOptional.absent(), + required String strategyPublicId, + ConvexOptional< + OpsApplyBatchArgsOpsItemStrategyPatchPayloadThemeOverridePalette + > + themeOverridePalette = + const ConvexOptional.absent(), + ConvexOptional themeProfileId = const ConvexOptional.absent(), + }) { + final args = encodeStrategiesUpdateArgs( + clearThemeOverridePalette: clearThemeOverridePalette, + clearThemeProfileId: clearThemeProfileId, + clientProtocolVersion: clientProtocolVersion, + expectedRevision: expectedRevision, + mapData: mapData, + name: name, + strategyPublicId: strategyPublicId, + themeOverridePalette: themeOverridePalette, + themeProfileId: themeProfileId, + ); + return _invoke( + () => _transport.mutation('strategies:update', args), + decodeStrategiesUpdateResult, + ); + } +} + +abstract interface class StrategyModule { + ConvexQuery getFullSnapshot({ + ConvexOptional acceptsTrashedPagesLeftOut = + const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }); + ConvexQuery getShell({ + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }); +} + +final class _StrategyModule implements StrategyModule { + const _StrategyModule(this._transport); + final ConvexTransport _transport; + @override + ConvexQuery getFullSnapshot({ + ConvexOptional acceptsTrashedPagesLeftOut = + const ConvexOptional.absent(), + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }) { + final args = encodeStrategyGetFullSnapshotArgs( + acceptsTrashedPagesLeftOut: acceptsTrashedPagesLeftOut, + clientProtocolVersion: clientProtocolVersion, + shareToken: shareToken, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'strategy:getFullSnapshot', + args: args, + decode: decodeStrategyGetFullSnapshotResult, + ); + } + + @override + ConvexQuery getShell({ + required double clientProtocolVersion, + ConvexOptional shareToken = const ConvexOptional.absent(), + required String strategyPublicId, + }) { + final args = encodeStrategyGetShellArgs( + clientProtocolVersion: clientProtocolVersion, + shareToken: shareToken, + strategyPublicId: strategyPublicId, + ); + return ConvexQuery( + transport: _transport, + name: 'strategy:getShell', + args: args, + decode: decodeStrategyGetShellResult, + ); + } +} + +abstract interface class UsersModule { + Future ensureCurrentUser({ + required double clientProtocolVersion, + }); + ConvexQuery me(); +} + +final class _UsersModule implements UsersModule { + const _UsersModule(this._transport); + final ConvexTransport _transport; + @override + Future ensureCurrentUser({ + required double clientProtocolVersion, + }) { + final args = encodeUsersEnsureCurrentUserArgs( + clientProtocolVersion: clientProtocolVersion, + ); + return _invoke( + () => _transport.mutation('users:ensureCurrentUser', args), + decodeUsersEnsureCurrentUserResult, + ); + } + + @override + ConvexQuery me() { + final args = encodeUsersMeArgs(); + return ConvexQuery( + transport: _transport, + name: 'users:me', + args: args, + decode: decodeUsersMeResult, + ); + } +} diff --git a/lib/collab/pending_media_bytes_store.dart b/lib/collab/pending_media_bytes_store.dart new file mode 100644 index 00000000..9edfe1df --- /dev/null +++ b/lib/collab/pending_media_bytes_store.dart @@ -0,0 +1,130 @@ +import 'dart:typed_data'; + +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +/// Whose image bytes these are: the same account and asset scope as the +/// media outbox, plus the strategy, so one account's cleanup never touches +/// another account's source. +typedef PendingMediaKey = ({ + String accountId, + String strategyPublicId, + String assetPublicId, +}); + +String pendingMediaStorageKey(PendingMediaKey key) => [ + key.accountId, + key.strategyPublicId, + key.assetPublicId, + ].map(Uri.encodeComponent).join('|'); + +const pendingMediaRecordVersion = 1; + +/// Image bytes waiting to upload, on a device that keeps no image files +/// (web). [savedAt] tells an abandoned draft from one another tab is still +/// editing. +class PendingMediaRecord { + const PendingMediaRecord({ + required this.key, + required this.bytes, + required this.savedAt, + }); + + final PendingMediaKey key; + final Uint8List bytes; + final DateTime savedAt; +} + +/// The upload job lives in the media outbox; this holds only the bytes it +/// sends. +abstract class PendingMediaBytesStore { + /// Every readable record. Unreadable ones stay stored and are never + /// pruned. + List load(); + Future put(PendingMediaRecord record); + Future remove(PendingMediaKey key); +} + +/// Browser storage (IndexedDB through Hive), so a refresh mid-upload keeps +/// the image. The box is opened at startup only where images are not files. +class HivePendingMediaBytesStore implements PendingMediaBytesStore { + Box get _box => + Hive.box(HiveBoxNames.pendingMediaBytesBox); + + @override + List load() => [ + for (final entry in _box.toMap().entries) + if (_recordFromJson(entry.value) case final record? + when pendingMediaStorageKey(record.key) == '${entry.key}') + record, + ]; + + @override + Future put(PendingMediaRecord record) => + _box.put(pendingMediaStorageKey(record.key), _recordToJson(record)); + + @override + Future remove(PendingMediaKey key) => + _box.delete(pendingMediaStorageKey(key)); +} + +/// Keeps nothing across launches. Where images are files nothing is ever put +/// here. Tests share one instance between containers to act as a browser +/// that survives a refresh. +class MemoryPendingMediaBytesStore implements PendingMediaBytesStore { + MemoryPendingMediaBytesStore([Iterable records = const []]) + : values = { + for (final record in records) + pendingMediaStorageKey(record.key): record, + }; + + final Map values; + + @override + List load() => values.values.toList(); + + @override + Future put(PendingMediaRecord record) async { + values[pendingMediaStorageKey(record.key)] = record; + } + + @override + Future remove(PendingMediaKey key) async { + values.remove(pendingMediaStorageKey(key)); + } +} + +Map _recordToJson(PendingMediaRecord record) => { + 'version': pendingMediaRecordVersion, + 'accountId': record.key.accountId, + 'strategyPublicId': record.key.strategyPublicId, + 'assetPublicId': record.key.assetPublicId, + 'savedAt': record.savedAt.toUtc().toIso8601String(), + 'bytes': record.bytes, + }; + +PendingMediaRecord? _recordFromJson(Object? value) { + if (value is! Map) return null; + final accountId = value['accountId']; + final strategyPublicId = value['strategyPublicId']; + final assetPublicId = value['assetPublicId']; + final savedAt = DateTime.tryParse('${value['savedAt']}'); + final bytes = value['bytes']; + if (value['version'] != pendingMediaRecordVersion || + accountId is! String || + strategyPublicId is! String || + assetPublicId is! String || + savedAt == null || + bytes is! Uint8List) { + return null; + } + return PendingMediaRecord( + key: ( + accountId: accountId, + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + ), + bytes: bytes, + savedAt: savedAt.toLocal(), + ); +} diff --git a/lib/collab/presence/presence_models.dart b/lib/collab/presence/presence_models.dart new file mode 100644 index 00000000..e28717e5 --- /dev/null +++ b/lib/collab/presence/presence_models.dart @@ -0,0 +1,209 @@ +/// Who else has a cloud strategy open, and where their cursor is. +/// +/// Presence is never saved and never queued. It lives in the icarus-presence +/// Worker (presence/) for as long as someone is connected; Convex only signs +/// the pass that lets them in (convex/presence.ts). +library; + +/// Convex's permission to join one strategy's room until [expiresAt]. +class RoomPass { + const RoomPass({ + required this.url, + required this.pass, + required this.expiresAt, + }); + + final Uri url; + final String pass; + final DateTime expiresAt; +} + +/// A cursor in canonical (attack-side) world coordinates on one page, so every +/// viewer can place it whatever their zoom, window size, or side. +class PresenceCursor { + const PresenceCursor({ + required this.pageId, + required this.x, + required this.y, + }); + + final String pageId; + final double x; + final double y; + + @override + bool operator ==(Object other) => + other is PresenceCursor && + other.pageId == pageId && + other.x == x && + other.y == y; + + @override + int get hashCode => Object.hash(pageId, x, y); +} + +/// The lineup groups one connection is editing, on one page: the groups of +/// the lineups and spots it holds, places from, or has open. Edits to one +/// group at the same time conflict, so teammates see it before they start. +class PresenceEditing { + const PresenceEditing({required this.pageId, required this.groupIds}); + + final String pageId; + final Set groupIds; + + @override + bool operator ==(Object other) => + other is PresenceEditing && + other.pageId == pageId && + other.groupIds.length == groupIds.length && + other.groupIds.containsAll(groupIds); + + @override + int get hashCode => Object.hash(pageId, Object.hashAllUnordered(groupIds)); +} + +/// One connection to the room. The same person in two windows is two peers +/// with one [uid]. +class PresencePeer { + PresencePeer({ + required this.sid, + required this.uid, + required this.name, + required this.avatarUrl, + required this.role, + required this.cursor, + String? pageId, + this.editing, + }) : pageId = pageId ?? cursor?.pageId; + + final String sid; + final String uid; + final String name; + final String? avatarUrl; + + /// owner, editor, or viewer. + final String role; + final PresenceCursor? cursor; + + /// The page their cursor was last on. It stays when the pointer leaves + /// the map, since they are most likely still on that page; unknown until + /// their cursor first shows. + final String? pageId; + + /// The lineup groups they are editing, if any. + final PresenceEditing? editing; + + /// With [cursor] in place of the last one; hiding it keeps [pageId]. + PresencePeer withCursor(PresenceCursor? cursor) => PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: avatarUrl, + role: role, + cursor: cursor, + pageId: cursor?.pageId ?? pageId, + editing: editing, + ); + + /// With [editing] in place of what they were editing. + PresencePeer withEditing(PresenceEditing? editing) => PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: avatarUrl, + role: role, + cursor: cursor, + pageId: pageId, + editing: editing, + ); + + /// This profile, where [earlier] was: its cursor and its page. + PresencePeer at(PresencePeer earlier) => PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: avatarUrl, + role: role, + cursor: earlier.cursor, + pageId: earlier.pageId, + editing: editing ?? earlier.editing, + ); +} + +enum PresenceConnection { + /// Not a cloud strategy, or this deployment has no presence service. + off, + + /// Joining for the first time, or rejoining after the connection dropped. + connecting, + live, +} + +class PresenceRoomState { + const PresenceRoomState({ + required this.connection, + this.selfUid, + this.peers = const {}, + }); + + static const off = PresenceRoomState(connection: PresenceConnection.off); + + final PresenceConnection connection; + final String? selfUid; + + /// By session id; never includes this connection. + final Map peers; + + /// Everyone else here, one entry per person, in the order they arrived. + /// Your own other windows are not "someone else". + List get people { + final seen = {}; + return [ + for (final peer in peers.values) + if (peer.uid != selfUid && seen.add(peer.uid)) peer, + ]; + } + + /// Everyone else last seen on [pageId], one entry per person. Presence is + /// best-effort: someone whose cursor has not shown yet is not counted. + List peopleOn(String pageId) { + final seen = {}; + return [ + for (final peer in peers.values) + if (peer.uid != selfUid && peer.pageId == pageId && seen.add(peer.uid)) + peer, + ]; + } + + /// Everyone else editing lineup group [groupId] on [pageId], one entry + /// per person. + List editingLineupGroup(String pageId, String groupId) { + final seen = {}; + return [ + for (final peer in peers.values) + if (peer.uid != selfUid && + peer.editing?.pageId == pageId && + (peer.editing?.groupIds.contains(groupId) ?? false) && + seen.add(peer.uid)) + peer, + ]; + } + + /// Other people's cursors on [pageId]. + List cursorsOn(String pageId) => [ + for (final peer in peers.values) + if (peer.uid != selfUid && peer.cursor?.pageId == pageId) peer, + ]; + + PresenceRoomState copyWith({ + PresenceConnection? connection, + String? selfUid, + Map? peers, + }) { + return PresenceRoomState( + connection: connection ?? this.connection, + selfUid: selfUid ?? this.selfUid, + peers: peers ?? this.peers, + ); + } +} diff --git a/lib/collab/presence/presence_room.dart b/lib/collab/presence/presence_room.dart new file mode 100644 index 00000000..45ca9773 --- /dev/null +++ b/lib/collab/presence/presence_room.dart @@ -0,0 +1,440 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:clock/clock.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Asks Convex for a pass. Null means this deployment has no presence +/// service; a throw means the request failed (offline, access revoked). +typedef RoomPassIssuer = Future Function(); +typedef RoomSocketConnector = WebSocketChannel Function(Uri uri); + +/// Sent by the room when a pass runs out or a renewal is refused. +const int closePassExpired = 4001; + +/// One live connection to a strategy's presence room. +/// +/// It keeps itself connected: it rejoins with a fresh pass after any drop, +/// renews the pass before it runs out, and pings so a dead connection is +/// noticed. Cursor moves are throttled to [cursorInterval] and only the +/// latest position is ever sent; nothing is queued across a disconnect. +class PresenceRoom { + PresenceRoom({ + required RoomPassIssuer issuePass, + RoomSocketConnector? connect, + this.cursorInterval = const Duration(milliseconds: 50), + this.editingInterval = const Duration(milliseconds: 150), + this.keepAliveInterval = const Duration(seconds: 20), + this.silenceLimit = const Duration(seconds: 50), + this.maxRetryDelay = const Duration(seconds: 30), + }) : _issuePass = issuePass, + _connect = connect ?? WebSocketChannel.connect; + + final RoomPassIssuer _issuePass; + final RoomSocketConnector _connect; + final Duration cursorInterval; + + /// The room drops an editing message sent sooner than 100 ms after the + /// last; this keeps clear of it, and only the latest state goes out. + final Duration editingInterval; + final Duration keepAliveInterval; + final Duration silenceLimit; + final Duration maxRetryDelay; + + final _states = StreamController.broadcast(sync: true); + PresenceRoomState _state = + const PresenceRoomState(connection: PresenceConnection.connecting); + + WebSocketChannel? _channel; + StreamSubscription? _subscription; + Timer? _retryTimer; + Timer? _renewTimer; + Timer? _keepAliveTimer; + Timer? _cursorTimer; + Timer? _editingTimer; + DateTime? _lastEditingSentAt; + int _failures = 0; + DateTime _lastHeard = clock.now(); + DateTime _joinedAt = clock.now(); + bool _disposed = false; + + /// Increments on every (re)connect so late callbacks from an old socket + /// can tell they are stale. + int _generation = 0; + + /// Where the pointer is now, whether or not it has gone out yet. + PresenceCursor? _currentCursor; + PresenceCursor? _pendingCursor; + PresenceCursor? _sentCursor; + DateTime? _lastCursorSentAt; + + /// The lineup groups this user is editing now, and what the room has. + PresenceEditing? _currentEditing; + PresenceEditing? _sentEditing; + + PresenceRoomState get state => _state; + Stream get states => _states.stream; + + void start() => unawaited(_join()); + + /// Where this user's cursor is now. Throttled; the last position always + /// goes out. + void moveCursor(PresenceCursor cursor) { + _currentCursor = cursor; + _pendingCursor = cursor; + if (_cursorTimer != null) return; + final lastSent = _lastCursorSentAt; + final wait = lastSent == null + ? Duration.zero + : cursorInterval - clock.now().difference(lastSent); + if (wait <= Duration.zero) { + _flushCursor(); + } else { + _cursorTimer = Timer(wait, _flushCursor); + } + } + + /// The pointer left the map. + void hideCursor() { + _cursorTimer?.cancel(); + _cursorTimer = null; + _pendingCursor = null; + _currentCursor = null; + if (_sentCursor == null) return; + _sentCursor = null; + _send({'t': 'hide'}); + } + + /// The lineup groups this user is editing now; null for none. Sent when it + /// changes, and again after a rejoin. + void setEditing(PresenceEditing? editing) { + _currentEditing = editing; + if (_editingTimer != null) return; + final lastSent = _lastEditingSentAt; + final wait = lastSent == null + ? Duration.zero + : editingInterval - clock.now().difference(lastSent); + if (wait <= Duration.zero) { + _flushEditing(); + } else { + _editingTimer = Timer(wait, _flushEditing); + } + } + + void _flushEditing() { + // A send can come early (a rejoin resends at once); a scheduled one + // must not follow it inside the room's interval. + _editingTimer?.cancel(); + _editingTimer = null; + final editing = _currentEditing; + if (editing == _sentEditing) return; + final sent = _sentEditing; + // Clearing names the page it cleared, as the room expects a page. + final message = editing == null + ? (sent == null + ? null + : {'t': 'editing', 'page': sent.pageId, 'groups': const []}) + : { + 't': 'editing', + 'page': editing.pageId, + // The room refuses more than eight; in practice it is one or + // two. + 'groups': ([...editing.groupIds]..sort()).take(8).toList(), + }; + if (message == null) { + _sentEditing = editing; + } else if (_send(message)) { + _sentEditing = editing; + _lastEditingSentAt = clock.now(); + } + } + + void dispose() { + _disposed = true; + _generation++; + _closeSocket(); + _retryTimer?.cancel(); + _cursorTimer?.cancel(); + _editingTimer?.cancel(); + unawaited(_states.close()); + } + + void _flushCursor() { + _cursorTimer = null; + final cursor = _pendingCursor; + _pendingCursor = null; + if (cursor == null || cursor == _sentCursor) return; + if (_send({ + 't': 'cursor', + 'page': cursor.pageId, + 'x': _round(cursor.x), + 'y': _round(cursor.y), + })) { + _sentCursor = cursor; + _lastCursorSentAt = clock.now(); + } + } + + Future _join() async { + final generation = ++_generation; + RoomPass? pass; + try { + pass = await _issuePass(); + } catch (_) { + if (generation == _generation && !_disposed) _retryLater(); + return; + } + if (generation != _generation || _disposed) return; + if (pass == null) { + _emit(PresenceRoomState.off); + return; + } + + final WebSocketChannel channel; + try { + channel = _connect( + pass.url.replace(queryParameters: {'pass': pass.pass}), + ); + await channel.ready; + } catch (_) { + if (generation == _generation && !_disposed) _retryLater(); + return; + } + if (generation != _generation || _disposed) { + unawaited(channel.sink.close()); + return; + } + + _channel = channel; + _joinedAt = _lastHeard = clock.now(); + _subscription = channel.stream.listen( + (message) => _onMessage(generation, message), + onDone: () => _onDropped(generation), + onError: (Object _) => _onDropped(generation), + cancelOnError: true, + ); + _scheduleRenewal(generation, pass.expiresAt); + _keepAliveTimer = Timer.periodic(keepAliveInterval, (_) { + if (clock.now().difference(_lastHeard) > silenceLimit) { + _onDropped(generation); + } else { + _send('ping'); + } + }); + } + + void _onMessage(int generation, dynamic raw) { + if (generation != _generation) return; + _lastHeard = clock.now(); + if (raw is! String || raw == 'pong') return; + final Object? decoded; + try { + decoded = jsonDecode(raw); + } catch (_) { + return; + } + if (decoded is! Map) return; + final next = applyPresenceMessage(_state, decoded); + if (decoded['t'] == 'welcome') { + _failures = 0; + // After a rejoin the room has forgotten this cursor and what this user + // edits; send them again. + final cursor = _currentCursor; + if (cursor != null) moveCursor(cursor); + _sentEditing = null; + _flushEditing(); + } + if (!identical(next, _state)) _emit(next); + } + + void _onDropped(int generation) { + if (generation != _generation || _disposed) return; + // A pass that ran out after a healthy stretch (say, the laptop slept + // through its renewal) is not a failure: rejoin with a fresh one now. + // Anything quicker backs off, so a bad clock can't spin. + final expired = _channel?.closeCode == closePassExpired && + clock.now().difference(_joinedAt) > const Duration(seconds: 10); + _closeSocket(); + if (expired) { + unawaited(_join()); + } else { + _retryLater(); + } + } + + void _retryLater() { + _closeSocket(); + _emit(const PresenceRoomState(connection: PresenceConnection.connecting)); + final seconds = math.min( + maxRetryDelay.inSeconds, + math.pow(2, _failures).toInt(), + ); + _failures++; + _retryTimer?.cancel(); + _retryTimer = Timer(Duration(seconds: seconds), () => unawaited(_join())); + } + + void _scheduleRenewal(int generation, DateTime expiresAt) { + // Halfway through the pass's life. The expiry is the server's clock, so + // clamp it: a skewed local clock must neither spin nor let it lapse. + final halfLife = expiresAt.difference(clock.now()) ~/ 2; + final renewIn = halfLife < const Duration(seconds: 20) + ? const Duration(seconds: 20) + : halfLife > const Duration(seconds: 60) + ? const Duration(seconds: 60) + : halfLife; + _renewTimer?.cancel(); + _renewTimer = Timer(renewIn, () async { + RoomPass? pass; + try { + pass = await _issuePass(); + } catch (_) { + // Offline or access revoked. The room closes the socket when the + // current pass runs out; rejoining then decides which it was. + return; + } + if (generation != _generation || pass == null) return; + if (_send({'t': 'renew', 'pass': pass.pass})) { + _scheduleRenewal(generation, pass.expiresAt); + } + }); + } + + bool _send(Object message) { + final channel = _channel; + if (channel == null) return false; + try { + channel.sink.add(message is String ? message : jsonEncode(message)); + return true; + } catch (_) { + return false; + } + } + + void _closeSocket() { + _renewTimer?.cancel(); + _keepAliveTimer?.cancel(); + _renewTimer = null; + _keepAliveTimer = null; + unawaited(_subscription?.cancel()); + _subscription = null; + final channel = _channel; + _channel = null; + if (channel != null) unawaited(channel.sink.close()); + _sentCursor = null; + _sentEditing = null; + } + + void _emit(PresenceRoomState next) { + if (_disposed) return; + _state = next; + _states.add(next); + } +} + +/// The room state after one server message. Pure, so it is tested alone. +PresenceRoomState applyPresenceMessage( + PresenceRoomState state, + Map message, +) { + switch (message['t']) { + case 'welcome': + final peers = {}; + for (final raw in (message['peers'] as List? ?? const [])) { + final peer = _peerFrom(raw); + if (peer != null) peers[peer.sid] = peer; + } + return PresenceRoomState( + connection: PresenceConnection.live, + selfUid: message['uid'] as String?, + peers: peers, + ); + case 'join': + final peer = _peerFrom(message['peer']); + if (peer == null) return state; + // A repeat join is a profile refresh; keep the cursor and page we + // already have. + final existing = state.peers[peer.sid]; + return state.copyWith(peers: { + ...state.peers, + peer.sid: existing == null ? peer : peer.at(existing), + }); + case 'leave': + final sid = message['sid']; + if (!state.peers.containsKey(sid)) return state; + return state.copyWith(peers: {...state.peers}..remove(sid)); + case 'cursor': + final peer = state.peers[message['sid']]; + final cursor = _cursorFrom(message); + if (peer == null || cursor == null) return state; + return state.copyWith( + peers: {...state.peers, peer.sid: peer.withCursor(cursor)}, + ); + case 'hide': + final peer = state.peers[message['sid']]; + if (peer == null || peer.cursor == null) return state; + return state.copyWith( + peers: {...state.peers, peer.sid: peer.withCursor(null)}, + ); + case 'editing': + final peer = state.peers[message['sid']]; + if (peer == null) return state; + return state.copyWith( + peers: { + ...state.peers, + peer.sid: peer.withEditing(_editingFrom(message)) + }, + ); + default: + return state; + } +} + +PresencePeer? _peerFrom(Object? raw) { + if (raw is! Map) return null; + final sid = raw['sid']; + final uid = raw['uid']; + final name = raw['name']; + final role = raw['role']; + if (sid is! String || uid is! String || name is! String || role is! String) { + return null; + } + final avatar = raw['avatar']; + final cursor = raw['cursor']; + final editing = raw['editing']; + return PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: avatar is String && avatar.isNotEmpty ? avatar : null, + role: role, + cursor: cursor is Map ? _cursorFrom(cursor) : null, + editing: editing is Map ? _editingFrom(editing) : null, + ); +} + +/// What a peer is editing; null when it is nothing, or unreadable. +PresenceEditing? _editingFrom(Map raw) { + final page = raw['page']; + final groups = raw['groups']; + if (page is! String || groups is! List) return null; + final groupIds = { + for (final group in groups) + if (group is String && group.isNotEmpty) group, + }; + if (groupIds.isEmpty) return null; + return PresenceEditing(pageId: page, groupIds: groupIds); +} + +PresenceCursor? _cursorFrom(Map raw) { + final page = raw['page']; + final x = raw['x']; + final y = raw['y']; + if (page is! String || x is! num || y is! num) return null; + return PresenceCursor(pageId: page, x: x.toDouble(), y: y.toDouble()); +} + +/// A tenth of a world unit is far below a pixel; shorter messages. +double _round(double value) => (value * 10).roundToDouble() / 10; diff --git a/lib/collab/src/convex_client_native.dart b/lib/collab/src/convex_client_native.dart new file mode 100644 index 00000000..a7e5adfa --- /dev/null +++ b/lib/collab/src/convex_client_native.dart @@ -0,0 +1,251 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:convex_flutter/convex_flutter.dart' as native; +import 'package:icarus/collab/src/convex_client_types.dart'; + +class ConvexClient implements ConvexClientValueSource { + ConvexClient._(this._client, this.config); + + final native.ConvexClient _client; + final ConvexConfig config; + + static ConvexClient? _instance; + + static ConvexClient get instance { + final client = _instance; + if (client == null) { + throw StateError('ConvexClient has not been initialized.'); + } + return client; + } + + static Future initialize(ConvexConfig config) async { + await native.ConvexClient.initialize( + native.ConvexConfig( + deploymentUrl: config.deploymentUrl, + clientId: config.clientId, + operationTimeout: config.operationTimeout, + healthCheckQuery: config.healthCheckQuery, + ), + ); + _instance = ConvexClient._(native.ConvexClient.instance, config); + } + + Stream get connectionState => + _client.connectionState.map(_mapConnectionState); + + WebSocketConnectionState get currentConnectionState => + _mapConnectionState(_client.currentConnectionState); + + bool get isConnected => _client.isConnected; + + Stream get authState => _client.authState; + + bool get isAuthenticated => _client.isAuthenticated; + + Future query(String name, Map args) => + _client.query(name, args); + + @override + Future queryValue(String name, Map args) => + _readValue(() => _client.query(name, _convexDartToJson(args))); + + Future mutation({ + required String name, + required Map args, + }) => + _client.mutation(name: name, args: args); + + @override + Future mutationValue({ + required String name, + required Map args, + }) => + _readValue( + () => _client.mutation(name: name, args: _convexDartToJson(args)), + ); + + Future action({ + required String name, + required Map args, + }) => + _client.action(name: name, args: args); + + @override + Future actionValue({ + required String name, + required Map args, + }) => + _readValue( + () => _client.action(name: name, args: _convexDartToJson(args)), + ); + + Future subscribe({ + required String name, + required Map args, + required void Function(String value) onUpdate, + required void Function(String message, String? value) onError, + }) async { + final handle = await _client.subscribe( + name: name, + args: args, + onUpdate: onUpdate, + onError: onError, + ); + return _NativeSubscriptionHandle(handle); + } + + @override + Future subscribeValue({ + required String name, + required Map args, + required void Function(Object? value) onUpdate, + required void Function(ConvexClientFunctionError error) onError, + }) async { + final handle = await _client.subscribe( + name: name, + args: _convexDartToJson(args), + onUpdate: (value) => onUpdate(_convexJsonToDart(jsonDecode(value))), + onError: (message, value) => onError( + _nativeFunctionError(message: message, encodedData: value), + ), + ); + return _NativeSubscriptionHandle(handle); + } + + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + final handle = await _client.setAuthWithRefresh( + fetchToken: fetchToken, + onAuthChange: onAuthChange, + ); + return _NativeAuthHandleWrapper(handle); + } + + Future clearAuth() => _client.clearAuth(); + + Future reconnect() => _client.reconnect(); + + void dispose() => _client.dispose(); + + Future _readValue(Future Function() operation) async { + try { + return _convexJsonToDart(jsonDecode(await operation())); + } on native.ClientError_ConvexError catch (error) { + throw _nativeFunctionError( + message: 'Convex function failed', + encodedData: error.data, + ); + } + } + + static WebSocketConnectionState _mapConnectionState( + native.WebSocketConnectionState state, + ) { + return state == native.WebSocketConnectionState.connected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting; + } +} + +ConvexClientFunctionError _nativeFunctionError({ + required String message, + required String? encodedData, +}) { + Object? data; + if (encodedData != null) { + try { + data = _convexJsonToDart(jsonDecode(encodedData)); + } catch (_) { + data = encodedData; + } + } + final dataMap = data is Map ? data : const {}; + final rawCode = dataMap['code']?.toString() ?? 'CONVEX_ERROR'; + final structuredMessage = dataMap['message']; + return ConvexClientFunctionError( + rawCode: rawCode, + message: structuredMessage is String && structuredMessage.isNotEmpty + ? structuredMessage + : message, + data: data, + ); +} + +Map _convexDartToJson(Map value) => + Map.from(_convexValueToJson(value) as Map); + +Object? _convexValueToJson(Object? value) { + if (value is BigInt) { + final minimum = BigInt.parse('-9223372036854775808'); + final maximum = BigInt.parse('9223372036854775807'); + if (value < minimum || value > maximum) { + throw FormatException('Convex int64 is out of range: $value'); + } + final bytes = Uint8List(8); + ByteData.sublistView(bytes).setInt64(0, value.toInt(), Endian.little); + return {r'$integer': base64Encode(bytes)}; + } + if (value is Uint8List) { + return {r'$bytes': base64Encode(value)}; + } + if (value is double && (!value.isFinite || value.isNegative && value == 0)) { + final bytes = Uint8List(8); + ByteData.sublistView(bytes).setFloat64(0, value, Endian.little); + return {r'$float': base64Encode(bytes)}; + } + if (value is List) return value.map(_convexValueToJson).toList(); + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _convexValueToJson(entry.value), + }; + } + return value; +} + +Object? _convexJsonToDart(Object? value) { + if (value is List) return value.map(_convexJsonToDart).toList(); + if (value is! Map) return value; + if (value.length == 1 && value[r'$integer'] is String) { + final bytes = base64Decode(value[r'$integer'] as String); + if (bytes.length != 8) throw const FormatException('Invalid Convex int64'); + return BigInt.from(ByteData.sublistView(bytes).getInt64(0, Endian.little)); + } + if (value.length == 1 && value[r'$bytes'] is String) { + return Uint8List.fromList(base64Decode(value[r'$bytes'] as String)); + } + if (value.length == 1 && value[r'$float'] is String) { + final bytes = base64Decode(value[r'$float'] as String); + if (bytes.length != 8) { + throw const FormatException('Invalid Convex float64'); + } + return ByteData.sublistView(bytes).getFloat64(0, Endian.little); + } + return { + for (final entry in value.entries) + entry.key.toString(): _convexJsonToDart(entry.value), + }; +} + +class _NativeSubscriptionHandle implements SubscriptionHandle { + _NativeSubscriptionHandle(this._handle); + + final native.SubscriptionHandle _handle; + + @override + void cancel() => _handle.cancel(); +} + +class _NativeAuthHandleWrapper implements AuthHandleWrapper { + _NativeAuthHandleWrapper(this._handle); + + final native.AuthHandleWrapper _handle; + + @override + void dispose() => _handle.dispose(); +} diff --git a/lib/collab/src/convex_client_types.dart b/lib/collab/src/convex_client_types.dart new file mode 100644 index 00000000..c85b3238 --- /dev/null +++ b/lib/collab/src/convex_client_types.dart @@ -0,0 +1,64 @@ +const defaultConvexHealthCheckQuery = 'health:ping'; + +class ConvexConfig { + const ConvexConfig({ + required this.deploymentUrl, + required this.clientId, + this.operationTimeout = const Duration(seconds: 30), + this.healthCheckQuery, + }); + + final String deploymentUrl; + final String clientId; + final Duration operationTimeout; + final String? healthCheckQuery; +} + +enum WebSocketConnectionState { + connecting, + connected, +} + +abstract interface class SubscriptionHandle { + void cancel(); +} + +abstract interface class AuthHandleWrapper { + void dispose(); +} + +final class ConvexClientFunctionError implements Exception { + const ConvexClientFunctionError({ + required this.rawCode, + required this.message, + required this.data, + }); + + final String rawCode; + final String message; + final Object? data; + + @override + String toString() => 'ConvexClientFunctionError($rawCode, $message)'; +} + +abstract interface class ConvexClientValueSource { + Future queryValue(String name, Map args); + + Future mutationValue({ + required String name, + required Map args, + }); + + Future actionValue({ + required String name, + required Map args, + }); + + Future subscribeValue({ + required String name, + required Map args, + required void Function(Object? value) onUpdate, + required void Function(ConvexClientFunctionError error) onError, + }); +} diff --git a/lib/collab/src/convex_client_web.dart b/lib/collab/src/convex_client_web.dart new file mode 100644 index 00000000..b5696c94 --- /dev/null +++ b/lib/collab/src/convex_client_web.dart @@ -0,0 +1,464 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'package:icarus/collab/src/convex_client_types.dart'; + +@JS('convex.ConvexClient') +extension type _JsConvexClient._(JSObject _) implements JSObject { + external factory _JsConvexClient(String address); + + external JSPromise query(String name, JSAny? args); + external JSPromise mutation(String name, JSAny? args); + external JSPromise action(String name, JSAny? args); + external JSFunction onUpdate( + String name, + JSAny? args, + JSFunction onUpdate, [ + JSFunction? onError, + ]); + external void setAuth(JSFunction fetchToken, [JSFunction? onChange]); + + /// The underlying sync client. The browser `ConvexClient` has `setAuth` but + /// no `clearAuth`; clearing auth is only offered here. + external _JsBaseConvexClient get client; + external _JsConnectionState connectionState(); + external JSFunction subscribeToConnectionState(JSFunction callback); + external void close(); +} + +@JS() +extension type _JsBaseConvexClient._(JSObject _) implements JSObject { + external void clearAuth(); +} + +@JS() +extension type _JsConnectionState._(JSObject _) implements JSObject { + external bool get isWebSocketConnected; +} + +@JS('BigInt') +external JSBigInt _createJsBigInt(String value); + +@JS('String') +external String _jsString(JSAny? value); + +@JS('Object.keys') +external JSArray _jsObjectKeys(JSObject value); + +extension type _JsUint8ArrayView._(JSObject _) implements JSObject { + external JSArrayBuffer get buffer; +} + +@JS('Uint8Array') +extension type _JsReadableUint8Array._(JSObject _) implements JSObject { + external _JsReadableUint8Array(JSArrayBuffer buffer); + + external int get length; + + external int operator [](int index); +} + +class ConvexClient implements ConvexClientValueSource { + ConvexClient._(this._client, this.config) { + _setConnectionState( + _client.connectionState().isWebSocketConnected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting, + ); + _connectionStateCallback = ((JSAny? value) { + final connection = _JsConnectionState._(value as JSObject); + _setConnectionState( + connection.isWebSocketConnected + ? WebSocketConnectionState.connected + : WebSocketConnectionState.connecting, + ); + }).toJS; + _connectionStateUnsubscribe = + _client.subscribeToConnectionState(_connectionStateCallback!); + } + + final _JsConvexClient _client; + final ConvexConfig config; + final StreamController _connectionStateController = + StreamController.broadcast(); + final StreamController _authStateController = + StreamController.broadcast(); + + WebSocketConnectionState _connectionState = + WebSocketConnectionState.connecting; + bool _isAuthenticated = false; + int _authGeneration = 0; + JSFunction? _connectionStateCallback; + JSFunction? _connectionStateUnsubscribe; + bool _disposed = false; + + static ConvexClient? _instance; + + static ConvexClient get instance { + final client = _instance; + if (client == null) { + throw StateError('ConvexClient has not been initialized.'); + } + return client; + } + + static Future initialize(ConvexConfig config) async { + _instance = ConvexClient._(_JsConvexClient(config.deploymentUrl), config); + } + + Stream get connectionState => + _connectionStateController.stream; + + WebSocketConnectionState get currentConnectionState => _connectionState; + + bool get isConnected => + _connectionState == WebSocketConnectionState.connected; + + Stream get authState => _authStateController.stream; + + bool get isAuthenticated => _isAuthenticated; + + Future query(String name, Map args) { + return _awaitResult('Query $name', _client.query(name, args.jsify())); + } + + @override + Future queryValue(String name, Map args) { + return _awaitValue('Query $name', _client.query(name, _toJsConvex(args))); + } + + Future mutation({ + required String name, + required Map args, + }) { + return _awaitResult( + 'Mutation $name', + _client.mutation(name, args.jsify()), + ); + } + + @override + Future mutationValue({ + required String name, + required Map args, + }) { + return _awaitValue( + 'Mutation $name', + _client.mutation(name, _toJsConvex(args)), + ); + } + + Future action({ + required String name, + required Map args, + }) { + return _awaitResult('Action $name', _client.action(name, args.jsify())); + } + + @override + Future actionValue({ + required String name, + required Map args, + }) { + return _awaitValue( + 'Action $name', + _client.action(name, _toJsConvex(args)), + ); + } + + Future subscribe({ + required String name, + required Map args, + required void Function(String value) onUpdate, + required void Function(String message, String? value) onError, + }) async { + final updateCallback = ((JSAny? value, JSAny? _) { + onUpdate(jsonEncode(value?.dartify())); + }).toJS; + final errorCallback = ((JSAny? error, JSAny? _) { + onError(_jsErrorMessage(error), null); + }).toJS; + final unsubscribe = _client.onUpdate( + name, + args.jsify(), + updateCallback, + errorCallback, + ); + return _WebSubscriptionHandle( + unsubscribe: unsubscribe, + updateCallback: updateCallback, + errorCallback: errorCallback, + ); + } + + @override + Future subscribeValue({ + required String name, + required Map args, + required void Function(Object? value) onUpdate, + required void Function(ConvexClientFunctionError error) onError, + }) async { + final updateCallback = ((JSAny? value, JSAny? _) { + onUpdate(_fromJsConvex(value)); + }).toJS; + final errorCallback = ((JSAny? error, JSAny? _) { + onError(_jsFunctionError(error)); + }).toJS; + final unsubscribe = _client.onUpdate( + name, + _toJsConvex(args), + updateCallback, + errorCallback, + ); + return _WebSubscriptionHandle( + unsubscribe: unsubscribe, + updateCallback: updateCallback, + errorCallback: errorCallback, + ); + } + + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + final generation = ++_authGeneration; + final tokenCallback = ((JSAny? _) { + return fetchToken().then((token) => token?.toJS).toJS; + }).toJS; + final authCallback = ((JSBoolean authenticated) { + if (generation != _authGeneration) { + return; + } + final value = authenticated.toDart; + _setAuthenticated(value); + onAuthChange?.call(value); + }).toJS; + _client.setAuth(tokenCallback, authCallback); + return _WebAuthHandleWrapper( + onDispose: () { + if (generation != _authGeneration) { + return; + } + _authGeneration += 1; + _client.client.clearAuth(); + _setAuthenticated(false); + }, + tokenCallback: tokenCallback, + authCallback: authCallback, + ); + } + + Future clearAuth() async { + _authGeneration += 1; + _client.client.clearAuth(); + _setAuthenticated(false); + } + + Future reconnect() async { + if (isConnected) { + return true; + } + try { + await connectionState + .firstWhere((state) => state == WebSocketConnectionState.connected) + .timeout(const Duration(seconds: 5)); + return true; + } on TimeoutException { + return false; + } + } + + void dispose() { + if (_disposed) { + return; + } + _disposed = true; + _connectionStateUnsubscribe?.callAsFunction(); + _connectionStateUnsubscribe = null; + _connectionStateCallback = null; + _client.close(); + _connectionStateController.close(); + _authStateController.close(); + } + + Future _awaitResult( + String operation, + JSPromise promise, + ) async { + try { + final result = await promise.toDart.timeout(config.operationTimeout); + return jsonEncode(result?.dartify()); + } on TimeoutException { + throw TimeoutException('$operation timed out', config.operationTimeout); + } + } + + Future _awaitValue( + String operation, + JSPromise promise, + ) async { + try { + final result = await promise.toDart.timeout(config.operationTimeout); + return _fromJsConvex(result); + } on TimeoutException { + throw TimeoutException('$operation timed out', config.operationTimeout); + } catch (error) { + throw _jsFunctionError(error); + } + } + + void _setConnectionState(WebSocketConnectionState next) { + if (_connectionState == next) { + return; + } + _connectionState = next; + _connectionStateController.add(next); + } + + void _setAuthenticated(bool next) { + if (_isAuthenticated == next) { + return; + } + _isAuthenticated = next; + _authStateController.add(next); + } + + String _jsErrorMessage(JSAny? error) { + if (error == null) { + return 'Unknown Convex error'; + } + try { + final object = error as JSObject; + final message = object.getProperty('message'.toJS)?.dartify(); + if (message is String && message.isNotEmpty) { + return message; + } + final dartValue = error.dartify(); + return dartValue?.toString() ?? 'Unknown Convex error'; + } catch (_) { + return 'Unknown Convex error'; + } + } + + ConvexClientFunctionError _jsFunctionError(Object? error) { + JSAny? jsError; + try { + jsError = error as JSAny?; + } catch (_) { + return ConvexClientFunctionError( + rawCode: 'CONVEX_ERROR', + message: error?.toString() ?? 'Unknown Convex error', + data: null, + ); + } + + Object? data; + String? objectCode; + String message = _jsErrorMessage(jsError); + try { + final object = jsError as JSObject; + final rawData = object.getProperty('data'.toJS); + data = _fromJsConvex(rawData); + final rawObjectCode = object.getProperty('code'.toJS); + objectCode = rawObjectCode == null ? null : _jsString(rawObjectCode); + } catch (_) {} + final dataMap = data is Map ? data : const {}; + final rawCode = dataMap['code']?.toString() ?? objectCode ?? 'CONVEX_ERROR'; + final structuredMessage = dataMap['message']; + if (structuredMessage is String && structuredMessage.isNotEmpty) { + message = structuredMessage; + } + return ConvexClientFunctionError( + rawCode: rawCode, + message: message, + data: data, + ); + } +} + +JSAny? _toJsConvex(Object? value) { + if (value is BigInt) return _createJsBigInt(value.toString()); + if (value is Uint8List) { + final array = Uint8List.fromList(value).toJS; + return _JsUint8ArrayView._(array).buffer; + } + if (value is List) { + return value.map(_toJsConvex).toList(growable: false).toJS; + } + if (value is Map) { + final object = JSObject(); + for (final entry in value.entries) { + object.setProperty(entry.key.toString().toJS, _toJsConvex(entry.value)); + } + return object; + } + return value.jsify(); +} + +Object? _fromJsConvex(JSAny? value) { + if (value == null) return null; + if (value.isA()) return BigInt.parse(_jsString(value)); + if (value.isA()) { + final bytes = _JsReadableUint8Array(value as JSArrayBuffer); + return Uint8List.fromList([ + for (var index = 0; index < bytes.length; index += 1) bytes[index], + ]); + } + if (value.isA>()) { + return (value as JSArray) + .toDart + .map(_fromJsConvex) + .toList(growable: false); + } + if (value.isA()) { + final object = value as JSObject; + return { + for (final key in _jsObjectKeys(object).toDart) + key.toDart: _fromJsConvex( + object.getProperty(key), + ), + }; + } + return value.dartify(); +} + +class _WebSubscriptionHandle implements SubscriptionHandle { + _WebSubscriptionHandle({ + required JSFunction unsubscribe, + required JSFunction updateCallback, + required JSFunction errorCallback, + }) : _unsubscribe = unsubscribe, + _callbacks = [updateCallback, errorCallback]; + + JSFunction? _unsubscribe; + final List _callbacks; + + @override + void cancel() { + _unsubscribe?.callAsFunction(); + _unsubscribe = null; + _callbacks.clear(); + } +} + +class _WebAuthHandleWrapper implements AuthHandleWrapper { + _WebAuthHandleWrapper({ + required void Function() onDispose, + required JSFunction tokenCallback, + required JSFunction authCallback, + }) : _onDispose = onDispose, + _callbacks = [tokenCallback, authCallback]; + + void Function()? _onDispose; + final List _callbacks; + + @override + void dispose() { + _onDispose?.call(); + _onDispose = null; + _callbacks.clear(); + } +} diff --git a/lib/collab/strategy_capabilities.dart b/lib/collab/strategy_capabilities.dart new file mode 100644 index 00000000..e970cd67 --- /dev/null +++ b/lib/collab/strategy_capabilities.dart @@ -0,0 +1,70 @@ +class StrategyCapabilities { + const StrategyCapabilities({ + required this.canRenameStrategy, + required this.canDeleteStrategy, + required this.canDuplicateStrategy, + required this.canMoveStrategy, + required this.canEditPages, + required this.canAddPage, + required this.canRenamePage, + required this.canDeletePage, + required this.canReorderPages, + required this.canCreateFolder, + required this.canEditFolder, + required this.canDeleteFolder, + required this.canMoveFolder, + }); + + final bool canRenameStrategy; + final bool canDeleteStrategy; + final bool canDuplicateStrategy; + final bool canMoveStrategy; + final bool canEditPages; + final bool canAddPage; + final bool canRenamePage; + final bool canDeletePage; + final bool canReorderPages; + final bool canCreateFolder; + final bool canEditFolder; + final bool canDeleteFolder; + final bool canMoveFolder; + + factory StrategyCapabilities.fullAccess() { + return const StrategyCapabilities( + canRenameStrategy: true, + canDeleteStrategy: true, + canDuplicateStrategy: true, + canMoveStrategy: true, + canEditPages: true, + canAddPage: true, + canRenamePage: true, + canDeletePage: true, + canReorderPages: true, + canCreateFolder: true, + canEditFolder: true, + canDeleteFolder: true, + canMoveFolder: true, + ); + } + + factory StrategyCapabilities.fromCloudRole(String? role) { + final normalized = role ?? 'viewer'; + final canEdit = normalized == 'owner' || normalized == 'editor'; + final isOwner = normalized == 'owner'; + return StrategyCapabilities( + canRenameStrategy: canEdit, + canDeleteStrategy: isOwner, + canDuplicateStrategy: canEdit, + canMoveStrategy: isOwner, + canEditPages: canEdit, + canAddPage: canEdit, + canRenamePage: canEdit, + canDeletePage: canEdit, + canReorderPages: canEdit, + canCreateFolder: isOwner, + canEditFolder: isOwner, + canDeleteFolder: isOwner, + canMoveFolder: isOwner, + ); + } +} diff --git a/lib/collab/transport/convex_transport.dart b/lib/collab/transport/convex_transport.dart new file mode 100644 index 00000000..f3d07200 --- /dev/null +++ b/lib/collab/transport/convex_transport.dart @@ -0,0 +1,144 @@ +import 'dart:typed_data'; + +sealed class ConvexValue { + const ConvexValue(); + + factory ConvexValue.fromDart(Object? value) { + if (value == null) return const ConvexNull(); + if (value is bool) return ConvexBoolean(value); + if (value is int) return ConvexInteger(value); + if (value is BigInt) return ConvexBigInt(value); + if (value is double) return ConvexFloat(value); + if (value is String) return ConvexString(value); + if (value is Uint8List) return ConvexBytes(value); + if (value is List) { + return ConvexArray(value.map(ConvexValue.fromDart).toList()); + } + if (value is Map) { + return ConvexObject({ + for (final entry in value.entries) + entry.key.toString(): ConvexValue.fromDart(entry.value), + }); + } + throw FormatException('Unsupported Convex value ${value.runtimeType}'); + } + + Object? toDart(); +} + +final class ConvexNull extends ConvexValue { + const ConvexNull(); + + @override + Object? toDart() => null; +} + +final class ConvexBoolean extends ConvexValue { + const ConvexBoolean(this.value); + + final bool value; + + @override + bool toDart() => value; +} + +final class ConvexInteger extends ConvexValue { + const ConvexInteger(this.value); + + final int value; + + @override + int toDart() => value; +} + +final class ConvexFloat extends ConvexValue { + const ConvexFloat(this.value); + + final double value; + + @override + double toDart() => value; +} + +final class ConvexBigInt extends ConvexValue { + const ConvexBigInt(this.value); + + final BigInt value; + + @override + BigInt toDart() => value; +} + +final class ConvexString extends ConvexValue { + const ConvexString(this.value); + + final String value; + + @override + String toDart() => value; +} + +final class ConvexBytes extends ConvexValue { + ConvexBytes(Uint8List value) : value = Uint8List.fromList(value); + + final Uint8List value; + + @override + Uint8List toDart() => Uint8List.fromList(value); +} + +final class ConvexArray extends ConvexValue { + ConvexArray(List value) : value = List.unmodifiable(value); + + final List value; + + @override + List toDart() => value.map((item) => item.toDart()).toList(); +} + +final class ConvexObject extends ConvexValue { + ConvexObject(Map value) + : value = Map.unmodifiable(value); + + final Map value; + + @override + Map toDart() => { + for (final entry in value.entries) entry.key: entry.value.toDart(), + }; +} + +final class ConvexTransportError implements Exception { + const ConvexTransportError({ + required this.rawCode, + required this.message, + this.data, + }); + + final String rawCode; + final String message; + final ConvexValue? data; + + @override + String toString() => 'ConvexTransportError($rawCode, $message)'; +} + +final class ConvexNormalizationError implements Exception { + const ConvexNormalizationError(this.path, this.cause); + + final String path; + final Object cause; + + @override + String toString() => 'ConvexNormalizationError($path, $cause)'; +} + +abstract interface class ConvexTransport { + Future query(String name, ConvexObject args); + + Future mutation(String name, ConvexObject args); + + Future action(String name, ConvexObject args); + + Stream subscribe(String name, ConvexObject args); +} diff --git a/lib/collab/transport/convex_transport_adapter.dart b/lib/collab/transport/convex_transport_adapter.dart new file mode 100644 index 00000000..ed496153 --- /dev/null +++ b/lib/collab/transport/convex_transport_adapter.dart @@ -0,0 +1,2 @@ +export 'src/convex_transport_native.dart' + if (dart.library.js_interop) 'src/convex_transport_web.dart'; diff --git a/lib/collab/transport/src/convex_transport_native.dart b/lib/collab/transport/src/convex_transport_native.dart new file mode 100644 index 00000000..9f7343e6 --- /dev/null +++ b/lib/collab/transport/src/convex_transport_native.dart @@ -0,0 +1,5 @@ +import 'package:icarus/collab/transport/src/normalized_convex_transport.dart'; + +final class PlatformConvexTransport extends NormalizedConvexTransport { + const PlatformConvexTransport(super.client); +} diff --git a/lib/collab/transport/src/convex_transport_web.dart b/lib/collab/transport/src/convex_transport_web.dart new file mode 100644 index 00000000..9f7343e6 --- /dev/null +++ b/lib/collab/transport/src/convex_transport_web.dart @@ -0,0 +1,5 @@ +import 'package:icarus/collab/transport/src/normalized_convex_transport.dart'; + +final class PlatformConvexTransport extends NormalizedConvexTransport { + const PlatformConvexTransport(super.client); +} diff --git a/lib/collab/transport/src/normalized_convex_transport.dart b/lib/collab/transport/src/normalized_convex_transport.dart new file mode 100644 index 00000000..55f10814 --- /dev/null +++ b/lib/collab/transport/src/normalized_convex_transport.dart @@ -0,0 +1,119 @@ +import 'dart:async'; + +import 'package:icarus/collab/src/convex_client_types.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +abstract base class NormalizedConvexTransport implements ConvexTransport { + const NormalizedConvexTransport(this.client); + + final ConvexClientValueSource client; + + @override + Future query(String name, ConvexObject args) => _invoke( + '$name.returns', + () => client.queryValue(name, args.toDart()), + ); + + @override + Future mutation(String name, ConvexObject args) => _invoke( + '$name.returns', + () => client.mutationValue(name: name, args: args.toDart()), + ); + + @override + Future action(String name, ConvexObject args) => _invoke( + '$name.returns', + () => client.actionValue(name: name, args: args.toDart()), + ); + + Future _invoke( + String path, + Future Function() operation, + ) async { + try { + return _normalize(await operation(), path); + } on ConvexClientFunctionError catch (error) { + throw _transportError(error); + } + } + + @override + Stream subscribe(String name, ConvexObject args) { + late final StreamController controller; + SubscriptionHandle? handle; + var active = false; + + Future closeForContractFailure( + Object error, StackTrace stackTrace) async { + if (!active) return; + active = false; + controller.addError(error, stackTrace); + handle?.cancel(); + handle = null; + await controller.close(); + } + + Future start() async { + active = true; + try { + final nextHandle = await client.subscribeValue( + name: name, + args: args.toDart(), + onUpdate: (value) { + if (!active) return; + try { + controller.add(_normalize(value, '$name.returns')); + } catch (error, stackTrace) { + closeForContractFailure(error, stackTrace); + } + }, + onError: (error) { + if (!active) return; + controller.addError(_transportError(error)); + }, + ); + if (!active) { + nextHandle.cancel(); + return; + } + handle = nextHandle; + } catch (error, stackTrace) { + await closeForContractFailure(error, stackTrace); + } + } + + controller = StreamController( + onListen: start, + onCancel: () { + active = false; + handle?.cancel(); + handle = null; + }, + ); + return controller.stream; + } + + ConvexValue _normalize(Object? value, String path) { + try { + return ConvexValue.fromDart(value); + } catch (error) { + throw ConvexNormalizationError(path, error); + } + } + + ConvexTransportError _transportError(ConvexClientFunctionError error) { + ConvexValue? data; + if (error.data != null) { + try { + data = ConvexValue.fromDart(error.data); + } catch (_) { + data = ConvexString(error.data.toString()); + } + } + return ConvexTransportError( + rawCode: error.rawCode, + message: error.message, + data: data, + ); + } +} diff --git a/lib/config/cloud_build_config.dart b/lib/config/cloud_build_config.dart new file mode 100644 index 00000000..5d1d2597 --- /dev/null +++ b/lib/config/cloud_build_config.dart @@ -0,0 +1,147 @@ +const String developmentConvexDeploymentUrl = + 'https://majestic-eel-413.convex.cloud'; +const String developmentConvexClientId = 'dev:majestic-eel-413'; +const String _developmentConvexHost = 'majestic-eel-413.convex.cloud'; +final RegExp _convexDeploymentHost = RegExp( + r'^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud$', +); + +const String _compiledCloudEnvironment = String.fromEnvironment( + 'ICARUS_CLOUD_ENVIRONMENT', +); +const String _compiledConvexDeploymentUrl = String.fromEnvironment( + 'ICARUS_CONVEX_DEPLOYMENT_URL', +); +const String _compiledConvexClientId = String.fromEnvironment( + 'ICARUS_CONVEX_CLIENT_ID', +); + +class CloudBuildConfig { + const CloudBuildConfig._({ + required this.environment, + required this.deploymentUrl, + required this.clientId, + }); + + final String environment; + final String deploymentUrl; + final String clientId; + + factory CloudBuildConfig.fromEnvironment({required bool isReleaseMode}) { + return CloudBuildConfig.forBuild( + isReleaseMode: isReleaseMode, + environment: _compiledCloudEnvironment, + deploymentUrl: _compiledConvexDeploymentUrl, + clientId: _compiledConvexClientId, + ); + } + + factory CloudBuildConfig.forBuild({ + required bool isReleaseMode, + String environment = '', + String deploymentUrl = '', + String clientId = '', + }) { + if (environment.trim().isEmpty) { + if (isReleaseMode) { + throw StateError( + 'Release builds require an explicit ICARUS_CLOUD_ENVIRONMENT. ' + "Use 'development' for CI or prerelease, or 'production' with " + 'production Convex values.', + ); + } + environment = 'development'; + } + + return CloudBuildConfig.resolve( + environment: environment, + deploymentUrl: deploymentUrl, + clientId: clientId, + ); + } + + factory CloudBuildConfig.resolve({ + required String environment, + String deploymentUrl = '', + String clientId = '', + }) { + final resolvedEnvironment = environment.trim().toLowerCase(); + final resolvedDeploymentUrl = deploymentUrl.trim(); + final resolvedClientId = clientId.trim(); + + switch (resolvedEnvironment) { + case 'development': + if (resolvedDeploymentUrl.isEmpty != resolvedClientId.isEmpty) { + throw StateError( + 'Development Convex overrides must include both ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID.', + ); + } + + final developmentUrl = resolvedDeploymentUrl.isEmpty + ? developmentConvexDeploymentUrl + : resolvedDeploymentUrl; + _requireHttpsUrl(developmentUrl); + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: developmentUrl, + clientId: resolvedClientId.isEmpty + ? developmentConvexClientId + : resolvedClientId, + ); + case 'production': + if (resolvedDeploymentUrl.isEmpty || resolvedClientId.isEmpty) { + throw StateError( + 'Production cloud builds require explicit ' + 'ICARUS_CONVEX_DEPLOYMENT_URL and ICARUS_CONVEX_CLIENT_ID values.', + ); + } + final productionUri = _requireProductionDeploymentUrl( + resolvedDeploymentUrl, + ); + if (productionUri.host == _developmentConvexHost || + resolvedClientId == developmentConvexClientId) { + throw StateError( + 'Production cloud builds cannot use the Icarus development ' + 'Convex deployment.', + ); + } + return CloudBuildConfig._( + environment: resolvedEnvironment, + deploymentUrl: resolvedDeploymentUrl, + clientId: resolvedClientId, + ); + default: + throw StateError( + "Unsupported ICARUS_CLOUD_ENVIRONMENT '$environment'. " + "Use 'development' or 'production'.", + ); + } + } + + static void _requireHttpsUrl(String value) { + final uri = Uri.tryParse(value); + if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) { + throw StateError('Convex deployment URL must be an absolute HTTPS URL.'); + } + } + + static Uri _requireProductionDeploymentUrl(String value) { + final uri = Uri.tryParse(value); + final hasCanonicalOrigin = uri != null && + uri.scheme == 'https' && + _convexDeploymentHost.hasMatch(uri.host.toLowerCase()) && + uri.userInfo.isEmpty && + !uri.hasPort && + (uri.path.isEmpty || uri.path == '/') && + !uri.hasQuery && + !uri.hasFragment; + if (!hasCanonicalOrigin) { + throw StateError( + 'Production Convex deployment URL must be a canonical ' + 'https://.convex.cloud URL.', + ); + } + return uri; + } +} diff --git a/lib/config/cloud_startup.dart b/lib/config/cloud_startup.dart new file mode 100644 index 00000000..abc12f0b --- /dev/null +++ b/lib/config/cloud_startup.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Whether cloud sync came up when the app started. When it did not, this run +/// is local mode only: nobody can sign in, the cloud outboxes are left +/// untouched on disk, and the library on this device works as usual. +class CloudStartup { + const CloudStartup._(this.unavailableReason); + + const CloudStartup.unavailable(String reason) : this._(reason); + + static const CloudStartup ready = CloudStartup._(null); + + /// What the user is told about it, or null when the cloud is up. + final String? unavailableReason; + + bool get isAvailable => unavailableReason == null; +} + +/// Set once by `main` before the app is shown. +final cloudStartupProvider = StateProvider( + (ref) => CloudStartup.ready, +); + +/// Raised by cloud actions in a run where cloud sync did not start. Its +/// [message] is the reason, worded for the user. +class CloudUnavailableException implements Exception { + const CloudUnavailableException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/lib/config/platform_policy.dart b/lib/config/platform_policy.dart new file mode 100644 index 00000000..3adcab4d --- /dev/null +++ b/lib/config/platform_policy.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// A feature one platform may not offer. [label] names it in user-facing +/// copy, so it reads as the subject of a sentence. +enum PlatformFeature { + exportFiles('Export'), + importFiles('Import'), + screenshot('Screenshot'), + videoExport('Video export'), + fileDrop('Drag and drop'), + addImages('Adding images'), + addLineups('Adding lineups'); + + const PlatformFeature(this.label); + + final String label; +} + +/// What this build of Icarus offers. The web beta is one value of this +/// class, so bringing a feature (or the local library) to the web is an edit +/// to [webBeta], not a hunt for scattered platform checks. +class PlatformPolicy { + const PlatformPolicy({ + required this.isWebBeta, + required this.allowsLocalLibrary, + required this.requiresSignIn, + required this.desktopOnly, + required this.comingToWebBeta, + }); + + /// The desktop app: everything, local and cloud. + static const desktop = PlatformPolicy( + isWebBeta: false, + allowsLocalLibrary: true, + requiresSignIn: false, + desktopOnly: {}, + comingToWebBeta: {}, + ); + + /// The web beta: signed in, cloud only. Strategies already saved in the + /// browser stay in its storage; they are hidden, not deleted. + static const webBeta = PlatformPolicy( + isWebBeta: true, + allowsLocalLibrary: false, + requiresSignIn: true, + desktopOnly: { + PlatformFeature.exportFiles, + PlatformFeature.importFiles, + PlatformFeature.fileDrop, + }, + // Empty for now; the Beta dialog hides its "Coming" list when it is. + comingToWebBeta: {}, + ); + + static const PlatformPolicy current = kIsWeb ? webBeta : desktop; + + /// Shows the Beta tag and its dialog. + final bool isWebBeta; + + /// Lists, creates, and imports strategies in the on-device library. + final bool allowsLocalLibrary; + + /// My Library opens only once the user is signed in to the cloud. + final bool requiresSignIn; + + /// Features that stay in the desktop app for now. + final Set desktopOnly; + + /// Features the web beta will gain; not here yet. + final Set comingToWebBeta; + + bool supports(PlatformFeature feature) => + !desktopOnly.contains(feature) && !comingToWebBeta.contains(feature); + + /// Why [feature] is not here, in one short sentence, or null when it is. + String? unavailableMessage(PlatformFeature feature) { + if (desktopOnly.contains(feature)) { + return '${feature.label} is desktop-only for now.'; + } + if (comingToWebBeta.contains(feature)) { + return '${feature.label} is coming to the web beta.'; + } + return null; + } +} + +/// Override with [PlatformPolicy.webBeta] to exercise the web beta in tests +/// without depending on `kIsWeb`. +final platformPolicyProvider = Provider( + (ref) => PlatformPolicy.current, +); diff --git a/lib/const/coordinate_system.dart b/lib/const/coordinate_system.dart index 482606d5..f8ed5c7e 100644 --- a/lib/const/coordinate_system.dart +++ b/lib/const/coordinate_system.dart @@ -6,7 +6,7 @@ class CoordinateSystem { // System parameters CoordinateSystem._({required Size playAreaSize}) - : _playAreaSize = playAreaSize; + : _playAreaSize = playAreaSize; final Size _playAreaSize; Size get playAreaSize => _playAreaSize; diff --git a/lib/const/hive_boxes.dart b/lib/const/hive_boxes.dart index 88594cc7..d841934a 100644 --- a/lib/const/hive_boxes.dart +++ b/lib/const/hive_boxes.dart @@ -4,5 +4,8 @@ class HiveBoxNames { static const mapThemeProfilesBox = "map_theme_profiles_box"; static const appPreferencesBox = "app_preferences_box"; static const favoriteAgentsBox = "favorite_agents_box"; + static const strategyOutboxBox = "strategy_outbox_box"; + static const cloudMediaOutboxBox = "cloud_media_outbox_box"; + static const pendingMediaBytesBox = "pending_media_bytes_box"; static const pinnedItemsBox = "pinned_items_box"; } diff --git a/lib/const/json_converters.dart b/lib/const/json_converters.dart index 8bd34473..e07dceb9 100644 --- a/lib/const/json_converters.dart +++ b/lib/const/json_converters.dart @@ -61,9 +61,19 @@ class AbilityInfoConverter @override AbilityInfo fromJson(Map json) { - final info = AgentData.agents[$enumDecode(_agentTypeEnumMap, json["type"])]! - .abilities[json["index"] as int]; - return info; + final type = $enumDecode(_agentTypeEnumMap, json['type']); + final rawIndex = json['index']; + if (rawIndex is! num || + !rawIndex.isFinite || + rawIndex.toInt().toDouble() != rawIndex.toDouble()) { + throw FormatException('Ability index must be an integer: $rawIndex'); + } + final index = rawIndex.toInt(); + final abilities = AgentData.agents[type]?.abilities; + if (abilities == null || index < 0 || index >= abilities.length) { + throw FormatException('Ability index $index is invalid for ${type.name}'); + } + return abilities[index]; } @override diff --git a/lib/const/line_provider.dart b/lib/const/line_provider.dart index 611f3bd3..3df9ada4 100644 --- a/lib/const/line_provider.dart +++ b/lib/const/line_provider.dart @@ -201,7 +201,6 @@ class SimpleImageData extends HiveObject { Map toJson() => _$SimpleImageDataToJson(this); } - @JsonSerializable() class LineUpOrigin extends HiveObject { final String id; @@ -361,9 +360,7 @@ class LineUpGraph { return LineUpGraph( origins: [ for (final origin in origins) - agent == null - ? origin - : origin.copyWith(agent: agent(origin.agent)), + agent == null ? origin : origin.copyWith(agent: agent(origin.agent)), ], landings: [ for (final landing in landings) @@ -451,9 +448,11 @@ class LineUpGraph { return fromLegacyGroups(lineUps.map(LineUpGroup.fromLegacyLineUp).toList()); } - /// Projection for readers that predate the graph: one group per origin, - /// each link becomes an item carrying a copy of its landing's ability. A - /// shared landing is copied once per origin. + /// Projection for local readers that predate the graph (the Hive fields an + /// older build reads after a rollback): one group per origin, each link + /// becomes an item carrying a copy of its landing's ability. A shared + /// landing is copied once per origin. Cloud sync stores the graph itself + /// (lib/collab/cloud_lineup_rows.dart) and never uses this. List toLegacyGroups() { final landingsById = {for (final landing in landings) landing.id: landing}; return [ @@ -601,27 +600,232 @@ class LineUpState { } } -class LineUpProviderSnapshot { - final LineUpGraph graph; - final Map popped; - final Map> actionLinkIds; +/// Adding or removing lineups: the origins, landings and links the change +/// put in ([after]) or took out ([before]). Undo and redo apply it to +/// whatever graph is current, so a page rehydrated in between (a cloud ack, +/// a teammate's edit) neither separates the action from its data nor rolls +/// back anyone else's lineups. +class LineUpGraphAction extends UserAction { + LineUpGraphAction({ + required super.type, + required super.id, + this.before = LineUpGraph.empty, + this.after = LineUpGraph.empty, + }) : super(group: ActionGroup.lineUp); + + final LineUpGraph before; + final LineUpGraph after; + + // History stores copies, so the copy must keep the change. + @override + LineUpGraphAction copy() { + return LineUpGraphAction( + type: type, + id: id, + before: before.deepCopy(), + after: after.deepCopy(), + ); + } +} + +/// The one part of a lineup entry an edit changes. +enum LineUpEditField { + originPosition, + landingPosition, + landingVisibility, + linkDetails +} - const LineUpProviderSnapshot({ - required this.graph, - required this.popped, - required this.actionLinkIds, +/// The properties of a link one edit changes, as that edit sets them. +/// Properties it left alone are null (or empty for images), so writing the +/// change keeps whatever they are now, including a teammate's edits. +class LineUpLinkChange { + const LineUpLinkChange({ + this.name, + this.youtubeLink, + this.notes, + this.addedImages = const [], + this.removedImageIds = const {}, }); + + final String? name; + final String? youtubeLink; + final String? notes; + + /// Images this change puts in, in order, each with the ids of the images + /// that followed it and are not being added themselves. Writing the change + /// inserts an image before the first of those still present, so undoing a + /// removal restores the order around any images a teammate added since. + final List<({SimpleImageData image, List followedBy})> addedImages; + final Set removedImageIds; + + bool get isEmpty => + name == null && + youtubeLink == null && + notes == null && + addedImages.isEmpty && + removedImageIds.isEmpty; + + /// The change from [before] to [after], and the change that undoes it. + static ({LineUpLinkChange forward, LineUpLinkChange backward}) between( + LineUpLink before, + LineUpLink after, + ) { + String? changed(String a, String b) => a == b ? null : b; + + /// The images of [target] missing from [source], placed by the images + /// of [target] that follow them and do exist in [source]. + List<({SimpleImageData image, List followedBy})> imagesOnlyIn( + List target, + List source, + ) { + final sourceIds = source.map((image) => image.id).toSet(); + return [ + for (var i = 0; i < target.length; i++) + if (!sourceIds.contains(target[i].id)) + ( + image: target[i], + followedBy: [ + for (final next in target.skip(i + 1)) + if (sourceIds.contains(next.id)) next.id, + ], + ), + ]; + } + + final added = imagesOnlyIn(after.images, before.images); + final removed = imagesOnlyIn(before.images, after.images); + return ( + forward: LineUpLinkChange( + name: changed(before.name, after.name), + youtubeLink: changed(before.youtubeLink, after.youtubeLink), + notes: changed(before.notes, after.notes), + addedImages: added, + removedImageIds: {for (final entry in removed) entry.image.id}, + ), + backward: LineUpLinkChange( + name: changed(after.name, before.name), + youtubeLink: changed(after.youtubeLink, before.youtubeLink), + notes: changed(after.notes, before.notes), + addedImages: removed, + removedImageIds: {for (final entry in added) entry.image.id}, + ), + ); + } + + LineUpLink writeTo(LineUpLink link) { + final images = [ + for (final image in link.images) + if (!removedImageIds.contains(image.id)) image.copyWith(), + ]; + for (final entry in addedImages) { + if (images.any((image) => image.id == entry.image.id)) continue; + final at = entry.followedBy + .map((id) => images.indexWhere((image) => image.id == id)) + .firstWhere((index) => index >= 0, orElse: () => images.length); + images.insert(at, entry.image.copyWith()); + } + return link.copyWith( + name: name ?? link.name, + youtubeLink: youtubeLink ?? link.youtubeLink, + notes: notes ?? link.notes, + images: images, + ); + } } -class LineUpProvider extends Notifier { - static const _uuid = Uuid(); +/// The visibility toggles one edit changes, as it sets them; toggles it left +/// alone are null. +class AbilityVisibilityChange { + const AbilityVisibilityChange({ + this.showRangeOutline, + this.showRangeFill, + this.showInnerOutline, + this.showInnerFill, + this.showVisionCone, + }); - /// Subgraphs removed by an undoable action, keyed by action id, waiting to - /// be restored. - final Map _popped = {}; + final bool? showRangeOutline; + final bool? showRangeFill; + final bool? showInnerOutline; + final bool? showInnerFill; + final bool? showVisionCone; + + bool get isEmpty => + showRangeOutline == null && + showRangeFill == null && + showInnerOutline == null && + showInnerFill == null && + showVisionCone == null; + + /// The change from [before] to [after], and the change that undoes it. + static ({AbilityVisibilityChange forward, AbilityVisibilityChange backward}) + between(AbilityVisualState before, AbilityVisualState after) { + bool? changed(bool a, bool b) => a == b ? null : b; + return ( + forward: AbilityVisibilityChange( + showRangeOutline: + changed(before.showRangeOutline, after.showRangeOutline), + showRangeFill: changed(before.showRangeFill, after.showRangeFill), + showInnerOutline: + changed(before.showInnerOutline, after.showInnerOutline), + showInnerFill: changed(before.showInnerFill, after.showInnerFill), + showVisionCone: changed(before.showVisionCone, after.showVisionCone), + ), + backward: AbilityVisibilityChange( + showRangeOutline: + changed(after.showRangeOutline, before.showRangeOutline), + showRangeFill: changed(after.showRangeFill, before.showRangeFill), + showInnerOutline: + changed(after.showInnerOutline, before.showInnerOutline), + showInnerFill: changed(after.showInnerFill, before.showInnerFill), + showVisionCone: changed(after.showVisionCone, before.showVisionCone), + ), + ); + } + + AbilityVisualState writeTo(AbilityVisualState state) => state.copyWith( + showRangeOutline: showRangeOutline, + showRangeFill: showRangeFill, + showInnerOutline: showInnerOutline, + showInnerFill: showInnerFill, + showVisionCone: showVisionCone, + ); +} - /// Which links each deletion action removes, so redo removes the same set. - final Map> _actionLinkIds = {}; +/// An edit to one field of one lineup entry. Undo and redo write only what +/// the edit changed onto the entry as it is now, so anything else changed +/// since (a teammate's weapon, image or toggle) is kept. The values are an +/// Offset for positions, an AbilityVisibilityChange for visibility and a +/// LineUpLinkChange for link details. +class LineUpEditAction extends UserAction { + LineUpEditAction({ + required super.id, + required this.targetId, + required this.field, + required this.before, + required this.after, + }) : super(type: ActionType.edit, group: ActionGroup.lineUp); + + final String targetId; + final LineUpEditField field; + final Object before; + final Object after; + + @override + LineUpEditAction copy() { + return LineUpEditAction( + id: id, + targetId: targetId, + field: field, + before: before, + after: after, + ); + } +} + +class LineUpProvider extends Notifier { + static const _uuid = Uuid(); @override LineUpState build() { @@ -721,14 +925,16 @@ class LineUpProvider extends Notifier { ); return; } - state = state.copyWith(placement: placement.copyWith(draftAbility: ability)); + state = + state.copyWith(placement: placement.copyWith(draftAbility: ability)); } void updateDraftAgentPosition(Offset position) { final draft = state.placement?.draftAgent; if (draft == null) return; draft.updatePosition(position); - state = state.copyWith(placement: state.placement!.copyWith(draftAgent: draft)); + state = + state.copyWith(placement: state.placement!.copyWith(draftAgent: draft)); } void setDraftAgentWeapon(WeaponType weapon) { @@ -789,6 +995,7 @@ class LineUpProvider extends Notifier { final placement = state.placement; if (placement == null || !placement.isComplete) return null; + final linkId = _uuid.v4(); final originId = placement.pinnedOriginId ?? _uuid.v4(); final landingId = placement.pinnedLandingId ?? _uuid.v4(); final origins = [...state.origins]; @@ -810,7 +1017,7 @@ class LineUpProvider extends Notifier { ); } final link = LineUpLink( - id: _uuid.v4(), + id: linkId, originId: originId, landingId: landingId, name: name, @@ -818,13 +1025,22 @@ class LineUpProvider extends Notifier { notes: notes, images: images.map((image) => image.copyWith()).toList(), ); - _recordAddition(link.id); + // Both endpoints are recorded even when pinned, so redo can recreate + // one that was removed in between (by a teammate, or an undo). + final added = LineUpGraph( + origins: [origins.firstWhere((origin) => origin.id == originId)], + landings: [landings.firstWhere((landing) => landing.id == landingId)], + links: [link], + ); state = state.copyWith( origins: origins, landings: landings, links: [...state.links, link], placement: null, ); + _record( + LineUpGraphAction(type: ActionType.addition, id: link.id, after: added), + ); return link; } @@ -852,51 +1068,132 @@ class LineUpProvider extends Notifier { state = state.copyWith(origins: origins); } - // --- Edits (no action recorded; callers wrap in performTransaction) ------- + // --- Edits (each records one undoable change to one field) -------------- + /// Records a change to the link's details: name, video link, notes and + /// images. Which origin and landing it joins is never edited here. void updateLink(LineUpLink link) { - final index = state.links.indexWhere((entry) => entry.id == link.id); - if (index < 0) return; - final links = [...state.links]; - links[index] = link; - state = state.copyWith(links: links); + final current = state.linkById(link.id); + if (current == null) return; + final change = LineUpLinkChange.between(current, link); + if (change.forward.isEmpty) return; + _recordEdit( + targetId: link.id, + field: LineUpEditField.linkDetails, + before: change.backward, + after: change.forward, + ); } - void updateLandingAbility(String landingId, PlacedAbility ability) { - final index = - state.landings.indexWhere((landing) => landing.id == landingId); - if (index < 0) return; - final landings = [...state.landings]; - landings[index] = landings[index].copyWith( - ability: ability.copyWith(lineUpID: landingId), + void updateOriginAgentPosition(String originId, Offset position) { + final current = state.originById(originId); + if (current == null) return; + _recordEdit( + targetId: originId, + field: LineUpEditField.originPosition, + before: current.agent.position, + after: position, + ); + } + + void updateLandingPosition(String landingId, Offset position) { + final current = state.landingById(landingId); + if (current == null) return; + _recordEdit( + targetId: landingId, + field: LineUpEditField.landingPosition, + before: current.ability.position, + after: position, ); - state = state.copyWith(landings: landings); } void updateLandingAbilityVisualState({ required String landingId, required AbilityVisualState visualState, }) { - final landing = state.landingById(landingId); - if (landing == null) return; - updateLandingAbility( - landingId, - landing.ability.copyWith(visualState: visualState), + final current = state.landingById(landingId); + if (current == null) return; + final change = AbilityVisibilityChange.between( + current.ability.visualState, visualState); + if (change.forward.isEmpty) return; + _recordEdit( + targetId: landingId, + field: LineUpEditField.landingVisibility, + before: change.backward, + after: change.forward, ); } + void _recordEdit({ + required String targetId, + required LineUpEditField field, + required Object before, + required Object after, + }) { + _writeField(field, targetId, after); + _record( + LineUpEditAction( + id: _uuid.v4(), + targetId: targetId, + field: field, + before: before, + after: after, + ), + ); + } + + /// Writes one field onto the entry as it is now; a missing entry is left + /// alone. + void _writeField(LineUpEditField field, String targetId, Object value) { + switch (field) { + case LineUpEditField.originPosition: + state = state.copyWith(origins: [ + for (final origin in state.origins) + origin.id == targetId + ? origin.copyWith( + agent: origin.agent.copyWith(position: value as Offset) + ..isDeleted = origin.agent.isDeleted, + ) + : origin, + ]); + case LineUpEditField.landingPosition: + case LineUpEditField.landingVisibility: + state = state.copyWith(landings: [ + for (final landing in state.landings) + landing.id == targetId + ? landing.copyWith( + ability: (field == LineUpEditField.landingPosition + ? landing.ability.copyWith(position: value as Offset) + : landing.ability.copyWith( + visualState: (value as AbilityVisibilityChange) + .writeTo(landing.ability.visualState), + )) + ..isDeleted = landing.ability.isDeleted, + ) + : landing, + ]); + case LineUpEditField.linkDetails: + state = state.copyWith(links: [ + for (final link in state.links) + link.id == targetId + ? (value as LineUpLinkChange).writeTo(link) + : link, + ]); + } + } + // --- Deletions ----------------------------------------------------------- void deleteLink(String linkId) { if (state.linkById(linkId) == null) return; - _recordDeletion(linkId, [linkId]); + _recordDeletion(linkId, {linkId}); } void deleteOrigin(String originId) { if (state.originById(originId) == null) return; _recordDeletion( originId, - state.linksFromOrigin(originId).map((link) => link.id).toList(), + state.linksFromOrigin(originId).map((link) => link.id).toSet(), ); } @@ -904,126 +1201,229 @@ class LineUpProvider extends Notifier { if (state.landingById(landingId) == null) return; _recordDeletion( landingId, - state.linksToLanding(landingId).map((link) => link.id).toList(), + state.linksToLanding(landingId).map((link) => link.id).toSet(), ); } - void _recordAddition(String linkId) { - _actionLinkIds[linkId] = [linkId]; - ref.read(actionProvider.notifier).addAction( - UserAction( - type: ActionType.addition, - id: linkId, - group: ActionGroup.lineUp, - ), - ); - } - - void _recordDeletion(String actionId, List linkIds) { - _actionLinkIds[actionId] = linkIds; - ref.read(actionProvider.notifier).addAction( - UserAction( - type: ActionType.deletion, - id: actionId, - group: ActionGroup.lineUp, - ), - ); - _removeForAction(actionId); - } - - /// Removes the links an action owns plus any origin or landing left with - /// no links, and parks the removed subgraph under the action id. - void _removeForAction(String actionId) { - final linkIds = (_actionLinkIds[actionId] ?? [actionId]).toSet(); + /// Removes [linkIds] (origins and landings left without a link go with + /// them) and records the links plus every endpoint they joined, shared ones + /// included. Undo can then rebuild a lineup whose shared landing was + /// removed in between, instead of restoring a link to nothing. + void _recordDeletion(String actionId, Set linkIds) { final removedLinks = state.links.where((link) => linkIds.contains(link.id)).toList(); if (removedLinks.isEmpty) return; - final remainingLinks = - state.links.where((link) => !linkIds.contains(link.id)).toList(); - final liveOriginIds = remainingLinks.map((link) => link.originId).toSet(); - final liveLandingIds = remainingLinks.map((link) => link.landingId).toSet(); + final originIds = removedLinks.map((link) => link.originId).toSet(); + final landingIds = removedLinks.map((link) => link.landingId).toSet(); + final removed = LineUpGraph( + origins: state.origins + .where((origin) => originIds.contains(origin.id)) + .toList(), + landings: state.landings + .where((landing) => landingIds.contains(landing.id)) + .toList(), + links: removedLinks, + ); + _apply(from: removed, to: LineUpGraph.empty); + _record( + LineUpGraphAction( + type: ActionType.deletion, + id: actionId, + before: removed, + ), + ); + } - final removedOrigins = state.origins - .where((origin) => !liveOriginIds.contains(origin.id)) - .toList(); - final removedLandings = state.landings - .where((landing) => !liveLandingIds.contains(landing.id)) - .toList(); + void _record(UserAction action) { + ref.read(actionProvider.notifier).addAction(action); + } + + /// Moves the graph from [from] to [to] for the entries they name, leaving + /// every other origin, landing and link as it currently is. Links only in + /// [from] are removed. Entries in [to] are put back only where missing: one + /// that exists now keeps its current state (edits since are kept, and a + /// restored link joins the surviving shared landing). An origin or landing + /// exists only while some link uses it, so any node left without a link + /// is dropped. + void _apply({required LineUpGraph from, required LineUpGraph to}) { + List upsert( + List current, + List incoming, + String Function(T) idOf, + ) { + final present = current.map(idOf).toSet(); + return [ + ...current, + for (final entry in incoming) + if (!present.contains(idOf(entry))) entry, + ]; + } - _popped[actionId] = LineUpGraph( - origins: removedOrigins, - landings: removedLandings, - links: removedLinks, + final droppedLinks = from.links.map((link) => link.id).toSet() + ..removeAll(to.links.map((link) => link.id)); + final links = upsert( + state.links.where((link) => !droppedLinks.contains(link.id)).toList(), + to.links, + (link) => link.id, ); + final usedOrigins = links.map((link) => link.originId).toSet(); + final usedLandings = links.map((link) => link.landingId).toSet(); + state = state.copyWith( - origins: state.origins - .where((origin) => liveOriginIds.contains(origin.id)) + origins: upsert(state.origins, to.origins, (origin) => origin.id) + .where((origin) => usedOrigins.contains(origin.id)) .toList(), - landings: state.landings - .where((landing) => liveLandingIds.contains(landing.id)) + landings: upsert(state.landings, to.landings, (landing) => landing.id) + .where((landing) => usedLandings.contains(landing.id)) .toList(), - links: remainingLinks, + links: links, ); } - void _restoreForAction(String actionId) { - final subgraph = _popped.remove(actionId); - if (subgraph == null) return; - final originIds = state.origins.map((origin) => origin.id).toSet(); - final landingIds = state.landings.map((landing) => landing.id).toSet(); - final linkIds = state.links.map((link) => link.id).toSet(); - state = state.copyWith( - origins: [ - ...state.origins, - ...subgraph.origins.where((origin) => !originIds.contains(origin.id)), - ], - landings: [ - ...state.landings, - ...subgraph.landings - .where((landing) => !landingIds.contains(landing.id)), - ], - links: [ - ...state.links, - ...subgraph.links.where((link) => !linkIds.contains(link.id)), - ], + /// Undoes or redoes a field edit on the entry as it is now and returns the + /// edit that reverses exactly what this changed: a position that moved, + /// the visibility toggles that flipped, the link details that took a new + /// value and the images actually put in or taken out. What a teammate + /// already changed is left out, so replaying the result can never undo + /// their work. Returns null when nothing changed or the entry is gone. + LineUpEditAction? replayEdit(LineUpEditAction action, {required bool undo}) { + final value = undo ? action.before : action.after; + final ({Object forward, Object backward})? applied = switch (action.field) { + LineUpEditField.originPosition => _positionChange( + state.originById(action.targetId)?.agent.position, + value as Offset, + ), + LineUpEditField.landingPosition => _positionChange( + state.landingById(action.targetId)?.ability.position, + value as Offset, + ), + LineUpEditField.landingVisibility => switch ( + state.landingById(action.targetId)?.ability.visualState) { + null => null, + final current => _nonEmpty(AbilityVisibilityChange.between( + current, + (value as AbilityVisibilityChange).writeTo(current), + )), + }, + LineUpEditField.linkDetails => switch (state.linkById(action.targetId)) { + null => null, + final link => _nonEmpty(LineUpLinkChange.between( + link, + (value as LineUpLinkChange).writeTo(link), + )), + }, + }; + if (applied == null) return null; + + _writeField(action.field, action.targetId, applied.forward); + // Undo writes an edit's `before` and redo its `after`. After an undo + // this entry goes on the redo stack: its redo reverses what the undo just + // did and a later undo repeats it. After a redo it is the other way round. + return LineUpEditAction( + id: action.id, + targetId: action.targetId, + field: action.field, + before: undo ? applied.forward : applied.backward, + after: undo ? applied.backward : applied.forward, + ); + } + + /// Moving from [current] to [target], or null when there is nothing to + /// move (the entry is gone or already there). + static ({Object forward, Object backward})? _positionChange( + Offset? current, + Offset target, + ) { + if (current == null || current == target) return null; + return (forward: target, backward: current); + } + + /// [change], or null when its forward half changes nothing. + static ({Object forward, Object backward})? _nonEmpty( + ({Object forward, Object backward}) change, + ) { + final forward = change.forward; + final isEmpty = switch (forward) { + AbilityVisibilityChange() => forward.isEmpty, + LineUpLinkChange() => forward.isEmpty, + _ => false, + }; + return isEmpty ? null : change; + } + + /// Undoes or redoes [action] on the graph as it is now and returns the + /// action that reverses exactly what this did: the links it took out, as + /// they were, and the links it put back, as they now are, each with its + /// endpoints. Links already gone are not taken out and links already there + /// are left as they are; when that leaves nothing to do, returns null. + LineUpGraphAction? replayGraphAction( + LineUpGraphAction action, { + required bool undo, + }) { + final from = undo ? action.after : action.before; + final to = undo ? action.before : action.after; + final kept = to.links.map((link) => link.id).toSet(); + final removing = { + for (final link in from.links) + if (!kept.contains(link.id) && state.linkById(link.id) != null) link.id, + }; + final inserting = { + for (final link in to.links) + if (state.linkById(link.id) == null) link.id, + }; + if (removing.isEmpty && inserting.isEmpty) return null; + + final removed = _currentEntries(removing); + _apply( + from: removed, + to: LineUpGraph( + origins: to.origins, + landings: to.landings, + links: [ + for (final link in to.links) + if (inserting.contains(link.id)) link, + ], + ), + ); + final inserted = _currentEntries(inserting); + return LineUpGraphAction( + type: action.type, + id: action.id, + before: undo ? inserted : removed, + after: undo ? removed : inserted, ); } + /// A copy of the links [linkIds] and the origins and landings they join, + /// as they are now. + LineUpGraph _currentEntries(Set linkIds) { + final links = + state.links.where((link) => linkIds.contains(link.id)).toList(); + final originIds = links.map((link) => link.originId).toSet(); + final landingIds = links.map((link) => link.landingId).toSet(); + return LineUpGraph( + origins: state.origins + .where((origin) => originIds.contains(origin.id)) + .toList(), + landings: state.landings + .where((landing) => landingIds.contains(landing.id)) + .toList(), + links: links, + ).deepCopy(); + } + + /// Undoes an origin weapon change. Graph changes replay through + /// [replayGraphAction] and field edits through [replayEdit]. void undoAction(UserAction action) { if (action is WeaponSelectionAction) { _applyOriginWeapon(action.id, action.before); - return; - } - switch (action.type) { - case ActionType.addition: - _removeForAction(action.id); - return; - case ActionType.deletion: - _restoreForAction(action.id); - return; - case ActionType.edit: - case ActionType.bulkDeletion: - case ActionType.transaction: - return; } } + /// Redoes an origin weapon change; see [undoAction]. void redoAction(UserAction action) { if (action is WeaponSelectionAction) { _applyOriginWeapon(action.id, action.after); - return; - } - switch (action.type) { - case ActionType.addition: - _restoreForAction(action.id); - return; - case ActionType.deletion: - _removeForAction(action.id); - return; - case ActionType.edit: - case ActionType.bulkDeletion: - case ActionType.transaction: - return; } } @@ -1038,6 +1438,29 @@ class LineUpProvider extends Notifier { ); } + /// Takes [graph] as the server's lineups merged with the ones the user + /// holds (see mergeHeldLineups). Held objects stay the ones on screen. + void mergeRemote(LineUpGraph graph) { + state = state.copyWith( + origins: [...graph.origins], + landings: [...graph.landings], + links: [...graph.links], + ); + } + + /// Puts [graph]'s lineups on the canvas beside the ones there: the user's + /// version of a conflicting lineup group, kept as a copy (see + /// forkLineUpGraph). A recovery step rather than an edit, so it is not + /// undoable, as taking the cloud's version is not. + void addRecovered(LineUpGraph graph) { + final copy = graph.deepCopy(); + state = state.copyWith( + origins: [...state.origins, ...copy.origins], + landings: [...state.landings, ...copy.landings], + links: [...state.links, ...copy.links], + ); + } + static String objectToJson(LineUpGraph graph) { return jsonEncode(graph.toJson()); } @@ -1060,44 +1483,8 @@ class LineUpProvider extends Notifier { } void clearAll() { - _popped.clear(); - _actionLinkIds.clear(); state = state.copyWith(origins: [], landings: [], links: []); } - - LineUpProviderSnapshot takeSnapshot() { - return LineUpProviderSnapshot( - graph: state.graph.deepCopy(), - popped: { - for (final entry in _popped.entries) entry.key: entry.value.deepCopy(), - }, - actionLinkIds: { - for (final entry in _actionLinkIds.entries) - entry.key: List.from(entry.value), - }, - ); - } - - void restoreSnapshot(LineUpProviderSnapshot snapshot) { - _popped - ..clear() - ..addAll({ - for (final entry in snapshot.popped.entries) - entry.key: entry.value.deepCopy(), - }); - _actionLinkIds - ..clear() - ..addAll({ - for (final entry in snapshot.actionLinkIds.entries) - entry.key: List.from(entry.value), - }); - final graph = snapshot.graph.deepCopy(); - state = state.copyWith( - origins: graph.origins, - landings: graph.landings, - links: graph.links, - ); - } } final lineUpProvider = diff --git a/lib/const/placed_classes.dart b/lib/const/placed_classes.dart index 950519fe..ee5b22d3 100644 --- a/lib/const/placed_classes.dart +++ b/lib/const/placed_classes.dart @@ -217,6 +217,7 @@ class PlacedImage extends PlacedWidget { required this.fileExtension, this.sizeVersion, this.tagColorValue, + this.link = '', }); final double aspectRatio; @@ -230,7 +231,8 @@ class PlacedImage extends PlacedWidget { @JsonKey(defaultValue: null) int? tagColorValue; - String link = ""; + @JsonKey(defaultValue: '') + String link; bool get usesWorldSize => (sizeVersion ?? 0) >= currentSizeVersion; @@ -274,7 +276,6 @@ class PlacedImage extends PlacedWidget { ); // Base class field // cloned.isDeleted = isDeleted ?? this.isDeleted; - // Mutable field specific to PlacedImage cloned.link = link ?? this.link; return cloned; } diff --git a/lib/const/placed_classes.g.dart b/lib/const/placed_classes.g.dart index ec5ef96a..0c1e8765 100644 --- a/lib/const/placed_classes.g.dart +++ b/lib/const/placed_classes.g.dart @@ -53,9 +53,8 @@ PlacedImage _$PlacedImageFromJson(Map json) => PlacedImage( fileExtension: json['fileExtension'] as String?, sizeVersion: (json['sizeVersion'] as num?)?.toInt(), tagColorValue: (json['tagColorValue'] as num?)?.toInt(), - ) - ..isDeleted = json['isDeleted'] as bool? ?? false - ..link = json['link'] as String; + link: json['link'] as String? ?? '', + )..isDeleted = json['isDeleted'] as bool? ?? false; Map _$PlacedImageToJson(PlacedImage instance) => { diff --git a/lib/const/placed_media_dimensions.dart b/lib/const/placed_media_dimensions.dart new file mode 100644 index 00000000..d1404002 --- /dev/null +++ b/lib/const/placed_media_dimensions.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; + +abstract final class PlacedImageDimensions { + static const double tagWidth = 10.0; + static const double tagGap = 2.0; + static const double imagePadding = 5.0; + + static Size screenSize({ + required CoordinateSystem coordinateSystem, + required double scale, + required double aspectRatio, + }) { + return _sizeForPixelWidth( + totalWidth: + coordinateSystem.worldWidthToScreen(ImageScalePolicy.clamp(scale)), + aspectRatio: aspectRatio, + ); + } + + static Size sizeForPixelsPerWorldUnit({ + required double pixelsPerWorldUnit, + required double scale, + required double aspectRatio, + }) { + return _sizeForPixelWidth( + totalWidth: ImageScalePolicy.clamp(scale) * pixelsPerWorldUnit, + aspectRatio: aspectRatio, + ); + } + + static Size _sizeForPixelWidth({ + required double totalWidth, + required double aspectRatio, + }) { + final safeAspectRatio = aspectRatio <= 0 ? 1.0 : aspectRatio; + final cardWidth = + (totalWidth - tagWidth - tagGap).clamp(1.0, double.infinity); + final contentWidth = + (cardWidth - (imagePadding * 2)).clamp(1.0, double.infinity); + final totalHeight = (contentWidth / safeAspectRatio) + (imagePadding * 2); + + return Size(totalWidth, totalHeight); + } +} + +abstract final class PlacedTextDimensions { + static const double tagWidth = 6.0; + static const double tagGap = 2.0; + static const double cardHorizontalPadding = 5.0; + static const double cardVerticalPadding = 6.0; + static const String emptyTextPlaceholder = 'Write here...'; + + static Size screenSize({ + required CoordinateSystem coordinateSystem, + required double widthWorld, + required double fontSizeWorld, + required String text, + }) { + return _sizeForPixels( + totalWidth: coordinateSystem.worldWidthToScreen(widthWorld), + fontSizePixels: coordinateSystem.worldHeightToScreen(fontSizeWorld), + text: text, + ); + } + + static Size sizeForPixelsPerWorldUnit({ + required double pixelsPerWorldUnit, + required double widthWorld, + required double fontSizeWorld, + required String text, + }) { + return _sizeForPixels( + totalWidth: widthWorld * pixelsPerWorldUnit, + fontSizePixels: fontSizeWorld * pixelsPerWorldUnit, + text: text, + ); + } + + static Size _sizeForPixels({ + required double totalWidth, + required double fontSizePixels, + required String text, + }) { + final maxContentWidth = + (totalWidth - tagWidth - tagGap - (cardHorizontalPadding * 2)) + .clamp(1.0, double.infinity); + + final displayText = text.isEmpty ? ' ' : _withBreakOpportunities(text); + final style = TextStyle( + fontSize: fontSizePixels, + height: 1.0, + ); + + final painter = TextPainter( + text: TextSpan( + text: displayText, + style: style, + ), + maxLines: null, + textDirection: TextDirection.ltr, + textScaler: TextScaler.noScaling, + )..layout(minWidth: maxContentWidth, maxWidth: maxContentWidth); + + final lineMetrics = painter.computeLineMetrics(); + // EditableText can keep one extra wrapped line in its scroll extent. + final wrappedTextSlack = lineMetrics.length > 1 && lineMetrics.isNotEmpty + ? lineMetrics.last.height.ceilToDouble() + : 0.0; + final totalHeight = painter.height.ceilToDouble() + + wrappedTextSlack + + (cardVerticalPadding * 2); + return Size(totalWidth, totalHeight); + } + + static double contentWidth({ + required CoordinateSystem coordinateSystem, + required double widthWorld, + }) { + final totalWidth = coordinateSystem.worldWidthToScreen(widthWorld); + return (totalWidth - tagWidth - tagGap - (cardHorizontalPadding * 2)) + .clamp(1.0, double.infinity); + } + + static double fontSizePx({ + required CoordinateSystem coordinateSystem, + required double fontSizeWorld, + }) { + return coordinateSystem.worldHeightToScreen(fontSizeWorld); + } + + static TextStyle textStyle({ + required CoordinateSystem coordinateSystem, + required double fontSizeWorld, + }) { + final fontSizePx = PlacedTextDimensions.fontSizePx( + coordinateSystem: coordinateSystem, + fontSizeWorld: fontSizeWorld, + ); + return TextStyle(fontSize: fontSizePx, height: 1.0); + } + + static String _withBreakOpportunities(String text) { + final buffer = StringBuffer(); + for (final rune in text.runes) { + buffer + ..writeCharCode(rune) + ..write('\u200B'); + } + return buffer.toString(); + } +} diff --git a/lib/const/placed_media_geometry.dart b/lib/const/placed_media_geometry.dart index cd650e92..b9882690 100644 --- a/lib/const/placed_media_geometry.dart +++ b/lib/const/placed_media_geometry.dart @@ -1,8 +1,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:icarus/const/image_scale_policy.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; abstract final class PlacedMediaGeometry { static const double _referencePixelsPerWorldUnit = 1080 / 1000; @@ -45,13 +45,11 @@ abstract final class PlacedMediaGeometry { final rawWidth = image.usesWorldSize ? image.scale : image.scale * _legacyWidthToWorldFactor; - final width = ImageScalePolicy.clamp(rawWidth); - final widthInPixels = width * _referencePixelsPerWorldUnit; - const leftChromeWidth = 12.0; - final cardWidth = math.max(1, widthInPixels - leftChromeWidth); - final aspectRatio = image.aspectRatio <= 0 ? 1.0 : image.aspectRatio; - final heightInPixels = (cardWidth - 10) / aspectRatio + 10; - - return Size(width, heightInPixels / _referencePixelsPerWorldUnit); + final screenSize = PlacedImageDimensions.sizeForPixelsPerWorldUnit( + pixelsPerWorldUnit: _referencePixelsPerWorldUnit, + scale: rawWidth, + aspectRatio: image.aspectRatio, + ); + return screenSize / _referencePixelsPerWorldUnit; } } diff --git a/lib/const/settings.dart b/lib/const/settings.dart index 9a50eb89..461f3eb0 100644 --- a/lib/const/settings.dart +++ b/lib/const/settings.dart @@ -98,6 +98,14 @@ class Settings { static const Color enemyOutlineColor = Color.fromARGB(139, 255, 82, 82); static const Color allyOutlineColor = Color.fromARGB(106, 105, 240, 175); + static Color get attackColor => tacticalVioletTheme.destructive; + + /// Defend-side label color. Deliberately NOT the violet primary (reserved + /// for selection/command per DESIGN.md) and not ally green (reserved for + /// team identity) — a muted steel blue in the family of the old + /// lightBlueAccent, toned down for the tactical palette. + static const Color defenderColor = Color(0xFF6BA6C9); + static Color get mixedStrategyColor => tacticalVioletTheme.mutedForeground; static Color neutralTeamShade(Color color) { return HSLColor.fromColor(color).withSaturation(0).toColor(); @@ -120,6 +128,8 @@ class Settings { static final Uri windowsStoreLink = Uri.parse( "https://apps.microsoft.com/detail/9PBWHHZRQFW6?hl=en-us&gl=US&ocid=pdpshare"); + static final Uri stableWindowsInstallerLink = Uri.parse( + "https://sunkenintime.github.io/icarus/downloads/windows/stable/icarus-setup-latest.exe"); static ThemeData appTheme = ThemeData( colorScheme: const ColorScheme.dark( // primary: Color.fromARGB(255, 129, 75, 223), @@ -244,6 +254,20 @@ class Settings { static const Color sightlineReportBlockingWall = Color(0xffd6a24a); static const Color sightlineReportClearWall = Color(0xff6b5527); static const Color sightlineReportCone = Color(0xff5da0e8); + + // Live presence on a cloud strategy. Each person gets one hue for their + // avatar ring, cursor, and name tag, picked from their id so every viewer + // sees them in the same color. None is the violet command hue or a + // tactical one (ally green, enemy red, defender blue, favorite amber). + static const List presenceColors = [ + Color(0xfff472b6), // pink-400 + Color(0xfffb923c), // orange-400 + Color(0xff22d3ee), // cyan-400 + Color(0xffa3e635), // lime-400 + Color(0xffe4e4e7), // zinc-200 + ]; + // Text on a presence name tag; every presence hue is light. + static const Color presenceTagInk = Color(0xff09090b); // zinc-950 // Resting glyph color for toolbar controls: a step under foreground so the // strip of icons stays quiet, but above mutedForeground, which vanishes at // the light stroke weights. Hover still comes up to foreground. @@ -335,14 +359,15 @@ class Settings { offset: Offset(0, 4), // Slight downward shift ); - static void showToast({ + static ToastificationItem showToast({ required String message, required Color backgroundColor, + Duration? autoCloseDuration = const Duration(seconds: 3), String? actionLabel, VoidCallback? onActionPressed, }) { - toastification.showCustom( - autoCloseDuration: const Duration(seconds: 3), + return toastification.showCustom( + autoCloseDuration: autoCloseDuration, alignment: Alignment.bottomCenter, builder: (context, holder) { final actionIsVisible = actionLabel != null && @@ -393,6 +418,16 @@ class Settings { ); } + static void dismissToast( + ToastificationItem toast, { + bool showRemoveAnimation = true, + }) { + toastification.dismiss( + toast, + showRemoveAnimation: showRemoveAnimation, + ); + } + static double utilityIconSize = 20; static double erasingSize = 15; } diff --git a/lib/const/sort_index_order.dart b/lib/const/sort_index_order.dart new file mode 100644 index 00000000..e061857a --- /dev/null +++ b/lib/const/sort_index_order.dart @@ -0,0 +1,16 @@ +import 'package:collection/collection.dart'; + +extension SortIndexOrder on List { + /// Sorts by sortIndex, keeping the current order within ties. + /// + /// Ties are real: legacy pages carry duplicate sortIndexes, and two clients + /// adding at once can both pick max+1. The server returns tied rows in + /// creation order. List.sort is not stable, so it could swap tied items + /// between two loads of the same data. + void sortBySortIndex(int Function(T item) sortIndexOf) { + mergeSort( + this, + compare: (a, b) => sortIndexOf(a).compareTo(sortIndexOf(b)), + ); + } +} diff --git a/lib/const/transition_data.dart b/lib/const/transition_data.dart index 8716fb08..6af8dba7 100644 --- a/lib/const/transition_data.dart +++ b/lib/const/transition_data.dart @@ -400,22 +400,22 @@ class PageTransitionEntry { required this.from, required this.to, this.order = 0, - }) : kind = TransitionKind.move, - id = to!.id; + }) : kind = TransitionKind.move, + id = to!.id; PageTransitionEntry.appear({required this.to, this.order = 0}) - : kind = TransitionKind.appear, - id = to!.id, - from = null; + : kind = TransitionKind.appear, + id = to!.id, + from = null; PageTransitionEntry.disappear({required this.from, this.order = 0}) - : kind = TransitionKind.disappear, - id = from!.id, - to = null; + : kind = TransitionKind.disappear, + id = from!.id, + to = null; // For unchanged items we still want to render during the transition // so they remain visible while the base view is hidden. PageTransitionEntry.none({required this.to, this.order = 0}) - : kind = TransitionKind.none, - id = to!.id, - from = null; + : kind = TransitionKind.none, + id = to!.id, + from = null; final String id; final TransitionKind kind; diff --git a/lib/const/update_checker.dart b/lib/const/update_checker.dart index 13cd94fc..573bc476 100644 --- a/lib/const/update_checker.dart +++ b/lib/const/update_checker.dart @@ -46,6 +46,15 @@ class UpdateChecker { @visibleForTesting static Future> Function()? windowsStoreCheckOverride; + /// The web build is always the deployed version; it has no updater, so it + /// never fetches the desktop version file. + static const _webResult = UpdateCheckResult( + isSupported: false, + isUpdateAvailable: false, + source: 'web', + message: 'The web build updates itself on reload.', + ); + static Future?> fetchVersionInfo() async { final fetchOverride = fetchVersionInfoOverride; if (fetchOverride != null) return fetchOverride(); @@ -69,7 +78,9 @@ class UpdateChecker { bool? isWindowsOverride, }) async { final bool isWeb = isWebOverride ?? kIsWeb; - final bool isWindows = isWindowsOverride ?? (!isWeb && Platform.isWindows); + if (isWeb) return _webResult; + final bool isWindows = isWindowsOverride ?? + ((!isWeb && Platform.isWindows) || windowsStoreCheckOverride != null); if (isWindows) { final windowsResult = await _checkWindowsStoreSignal(); @@ -95,7 +106,9 @@ class UpdateChecker { bool? isWindowsOverride, }) async { final bool isWeb = isWebOverride ?? kIsWeb; - final bool isWindows = isWindowsOverride ?? (!isWeb && Platform.isWindows); + if (isWeb) return _webResult; + final bool isWindows = isWindowsOverride ?? + ((!isWeb && Platform.isWindows) || windowsStoreCheckOverride != null); if (!isWindows) { return _checkRemoteVersionSignal(); diff --git a/lib/domain/folder.dart b/lib/domain/folder.dart new file mode 100644 index 00000000..101b5a94 --- /dev/null +++ b/lib/domain/folder.dart @@ -0,0 +1,105 @@ +// ignore_for_file: non_const_argument_for_const_parameter + +import 'package:flutter/material.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/const/settings.dart'; + +enum FolderColor { + generic, + red, + blue, + green, + orange, + purple, + custom, +} + +class Folder extends HiveObject { + Folder({ + required this.name, + required this.id, + required this.dateCreated, + int? iconId, + IconData? icon, + this.color = FolderColor.red, + this.parentID, + this.customColor, + }) : iconId = iconId ?? + (icon == null + ? FolderIconRegistry.defaultId + : FolderIconRegistry.idForLegacyIconData(icon)); + + String name; + final String id; + final DateTime dateCreated; + String? parentID; + int iconId; + FolderColor color; + Color? customColor; + + static Map folderColorMap = { + FolderColor.red: Colors.red, + FolderColor.blue: Colors.blue, + FolderColor.green: Colors.green, + FolderColor.orange: Colors.orange, + FolderColor.purple: Colors.purple, + FolderColor.generic: Settings.tacticalVioletTheme.card, + }; + + static List folderColors = [ + FolderColor.red, + FolderColor.blue, + FolderColor.green, + FolderColor.orange, + FolderColor.purple, + FolderColor.generic, + ]; + + @Deprecated('Use iconId and FolderIconRegistry instead.') + IconData get icon => FolderIconRegistry.legacyIconDataForId(iconId); + + @Deprecated('Use iconId and FolderIconRegistry instead.') + set icon(IconData icon) { + iconId = FolderIconRegistry.idForLegacyIconData(icon); + } + + @Deprecated('Use FolderIconRegistry.pickerEntries instead.') + static List get folderIcons => [ + for (final entry in FolderIconRegistry.pickerEntries) + if (entry.iconData != null) entry.iconData!, + ]; + + bool get isRoot => parentID == null; +} + +FolderColor folderColorFromWireName(String? value) { + if (value == null) return FolderColor.generic; + return FolderColor.values.firstWhere( + (color) => color.name == value, + orElse: () => FolderColor.generic, + ); +} + +Color? folderCustomColorFromCloud(int? value) { + return value == null ? null : Color(value); +} + +int folderIconIdFromCloud({ + required int? iconId, + required int? codePoint, + required String? fontFamily, + required String? fontPackage, +}) { + if (iconId != null && FolderIconRegistry.isKnownId(iconId)) { + return iconId; + } + final icon = codePoint == null + ? Icons.drive_folder_upload + : IconData( + codePoint, + fontFamily: fontFamily, + fontPackage: fontPackage, + ); + return FolderIconRegistry.idForLegacyIconData(icon); +} diff --git a/lib/hive/hive_adapters.dart b/lib/hive/hive_adapters.dart index 0ba01696..40fa1726 100644 --- a/lib/hive/hive_adapters.dart +++ b/lib/hive/hive_adapters.dart @@ -5,6 +5,7 @@ import 'dart:ui' show Offset; import 'package:flutter/material.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/bounding_box.dart'; @@ -17,16 +18,20 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +// Hand-written adapters own these typeIds. The generator drops yaml rows for +// types it does not generate, so reserving them is what keeps a future +// AdapterSpec from being handed one of them. @GenerateAdapters([ AdapterSpec(), AdapterSpec(), - AdapterSpec(), - AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), @@ -39,7 +44,6 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), - AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), @@ -47,6 +51,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), + AdapterSpec(), AdapterSpec(), AdapterSpec(), AdapterSpec(), @@ -55,14 +60,268 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; AdapterSpec(), AdapterSpec(), AdapterSpec(), -]) +], reservedTypeIds: { + placedAgentAdapterTypeId, + placedAbilityAdapterTypeId, + abilityInfoAdapterTypeId, + freeDrawingAdapterTypeId, + lineAdapterTypeId, + folderAdapterTypeId, + strategyPageAdapterTypeId, + rectangleDrawingAdapterTypeId, + ellipseDrawingAdapterTypeId, +}) part 'hive_adapters.g.dart'; +const int placedAgentAdapterTypeId = 2; +const int placedAbilityAdapterTypeId = 3; +const int abilityInfoAdapterTypeId = 9; +const int folderAdapterTypeId = 17; const int freeDrawingAdapterTypeId = 11; const int lineAdapterTypeId = 12; +const int strategyPageAdapterTypeId = 20; const int rectangleDrawingAdapterTypeId = 24; const int ellipseDrawingAdapterTypeId = 31; +/// Writes the record desktop 4.6.3 writes, so a library rolled back to that +/// release keeps every agent and firearm. Cloud builds before the main merge +/// left out field 7 when there was no firearm; reading defaults it. +class PlacedAgentAdapter extends TypeAdapter { + @override + final typeId = placedAgentAdapterTypeId; + + @override + PlacedAgent read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PlacedAgent( + type: fields[0] as AgentType, + position: fields[4] as Offset, + id: fields[2] as String, + isAlly: fields[1] == null ? true : fields[1] as bool, + lineUpID: fields[5] as String?, + state: fields[6] == null ? AgentState.none : fields[6] as AgentState, + weapon: fields[7] == null ? WeaponType.none : fields[7] as WeaponType, + )..isDeleted = fields[3] as bool; + } + + @override + void write(BinaryWriter writer, PlacedAgent obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.isAlly) + ..writeByte(2) + ..write(obj.id) + ..writeByte(3) + ..write(obj.isDeleted) + ..writeByte(4) + ..write(obj.position) + ..writeByte(5) + ..write(obj.lineUpID) + ..writeByte(6) + ..write(obj.state) + ..writeByte(7) + ..write(obj.weapon); + } +} + +/// Writes the record desktop 4.6.3 writes, visual state in field 9. Cloud +/// builds before the main merge kept it as primitives in fields 10-14 instead; +/// reading prefers those when present. +class PlacedAbilityAdapter extends TypeAdapter { + @override + final typeId = placedAbilityAdapterTypeId; + + @override + PlacedAbility read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + final legacyVisualState = fields[9] as AbilityVisualState?; + final visualState = AbilityVisualState( + showRangeOutline: + fields[10] as bool? ?? legacyVisualState?.showRangeOutline ?? true, + showRangeFill: + fields[11] as bool? ?? legacyVisualState?.showRangeFill ?? true, + showInnerOutline: + fields[12] as bool? ?? legacyVisualState?.showInnerOutline ?? true, + showInnerFill: + fields[13] as bool? ?? legacyVisualState?.showInnerFill ?? true, + showVisionCone: + fields[14] as bool? ?? legacyVisualState?.showVisionCone ?? true, + ); + + return PlacedAbility( + data: fields[0] as AbilityInfo, + position: fields[5] as Offset, + id: fields[3] as String, + isAlly: fields[1] == null ? true : fields[1] as bool, + length: fields[6] == null ? 0.0 : (fields[6] as num).toDouble(), + lineUpID: fields[7] as String?, + rotation: fields[2] == null ? 0.0 : (fields[2] as num).toDouble(), + visualState: visualState, + armLengthsMeters: (fields[8] as List?)?.cast(), + )..isDeleted = fields[4] as bool; + } + + @override + void write(BinaryWriter writer, PlacedAbility obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.data) + ..writeByte(1) + ..write(obj.isAlly) + ..writeByte(2) + ..write(obj.rotation) + ..writeByte(3) + ..write(obj.id) + ..writeByte(4) + ..write(obj.isDeleted) + ..writeByte(5) + ..write(obj.position) + ..writeByte(6) + ..write(obj.length) + ..writeByte(7) + ..write(obj.lineUpID) + ..writeByte(8) + ..write(obj.armLengthsMeters) + ..writeByte(9) + ..write(obj.visualState); + } +} + +/// Writes the record desktop 4.6.3 writes, so a library rolled back to that +/// release opens with every drawing, agent, ability and lineup, and keeps them +/// when that release saves. +/// +/// Cloud builds before the main merge wrote a 3.2.3-safe subset into the +/// legacy slots and the full page into JSON mirrors: agents at 13, drawings +/// at 18, the lineup graph at 19 and abilities at 20. Earlier still, drawings +/// at 15 and lineup groups at 16, which a String at 15 identifies. Reading +/// prefers any mirror present, so those pages load whole. +const int _pageAgentsJsonField = 13; +const int _pageDrawingsJsonField = 18; +const int _pageLineUpGraphJsonField = 19; +const int _pageAbilitiesJsonField = 20; + +class StrategyPageAdapter extends TypeAdapter { + @override + final typeId = strategyPageAdapterTypeId; + + @override + StrategyPage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + final isCloudLegacyLayout = fields[15] is String; + final drawingsJson = (fields[_pageDrawingsJsonField] ?? + (isCloudLegacyLayout ? fields[15] : null)) as String?; + final agentsJson = fields[_pageAgentsJsonField] as String?; + final abilitiesJson = fields[_pageAbilitiesJsonField] as String?; + final lineUpGraph = _readLineUpGraph(fields, isCloudLegacyLayout); + + return StrategyPage( + id: fields[0] as String, + name: fields[2] as String, + isAutoNamed: fields[14] as bool?, + drawingData: drawingsJson == null + ? (fields[3] as List).cast() + : DrawingProvider.fromJson(drawingsJson), + agentData: agentsJson == null + ? (fields[4] as List).cast() + : AgentProvider.fromJson(agentsJson), + abilityData: abilitiesJson == null + ? (fields[5] as List).cast() + : AbilityProvider.fromJson(abilitiesJson), + textData: (fields[6] as List).cast(), + imageData: (fields[7] as List).cast(), + utilityData: (fields[8] as List).cast(), + sortIndex: (fields[1] as num).toInt(), + isAttack: fields[9] as bool, + settings: fields[10] as StrategySettings, + lineUpOrigins: lineUpGraph.origins, + lineUpLandings: lineUpGraph.landings, + lineUpLinks: lineUpGraph.links, + lineUpGroups: fields[12] == null + ? const [] + : (fields[12] as List).cast(), + lineUps: + fields[11] == null ? const [] : (fields[11] as List).cast(), + ); + } + + static LineUpGraph _readLineUpGraph( + Map fields, + bool isCloudLegacyLayout, + ) { + final graphJson = fields[_pageLineUpGraphJsonField] as String?; + if (graphJson != null) return LineUpProvider.fromJson(graphJson); + if (isCloudLegacyLayout) { + final groupsJson = fields[16] as String?; + return groupsJson == null + ? LineUpGraph.empty + : LineUpGraph.fromLegacyGroups( + LineUpProvider.legacyGroupsFromJson(groupsJson), + ); + } + return LineUpGraph( + origins: (fields[15] as List?)?.cast() ?? const [], + landings: (fields[16] as List?)?.cast() ?? const [], + links: (fields[17] as List?)?.cast() ?? const [], + ); + } + + @override + void write(BinaryWriter writer, StrategyPage obj) { + writer + ..writeByte(17) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.sortIndex) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.drawingData) + ..writeByte(4) + ..write(obj.agentData) + ..writeByte(5) + ..write(obj.abilityData) + ..writeByte(6) + ..write(obj.textData) + ..writeByte(7) + ..write(obj.imageData) + ..writeByte(8) + ..write(obj.utilityData) + ..writeByte(9) + ..write(obj.isAttack) + ..writeByte(10) + ..write(obj.settings) + ..writeByte(11) + // ignore: deprecated_member_use_from_same_package + ..write(obj.lineUps) + ..writeByte(12) + // ignore: deprecated_member_use_from_same_package + ..write(obj.lineUpGroups) + ..writeByte(14) + ..write(obj.isAutoNamed) + ..writeByte(15) + ..write(obj.lineUpOrigins) + ..writeByte(16) + ..write(obj.lineUpLandings) + ..writeByte(17) + ..write(obj.lineUpLinks); + } +} + int _readDrawingHiveColorValue( Map fields, { required int colorFieldIndex, @@ -304,9 +563,12 @@ class EllipseDrawingAdapter extends TypeAdapter { } } +/// Writes the record desktop 4.6.3 writes: the icon id at 4, the custom color +/// as ARGB at 6. Cloud builds before the main merge wrote a legacy icon and a +/// Color there and the exact values at 7 and 8; reading prefers those. class FolderAdapter extends TypeAdapter { @override - final typeId = 17; + final typeId = folderAdapterTypeId; @override Folder read(BinaryReader reader) { @@ -319,9 +581,9 @@ class FolderAdapter extends TypeAdapter { id: fields[1] as String, parentID: fields[2] as String?, dateCreated: fields[3] as DateTime, - iconId: FolderIconRegistry.idForStoredValue(fields[4]), + iconId: FolderIconRegistry.idForStoredValue(fields[7] ?? fields[4]), color: fields[5] as FolderColor? ?? FolderColor.red, - customColor: switch (fields[6]) { + customColor: switch (fields[8] ?? fields[6]) { final int colorValue => Color(colorValue), final Color color => color, null => null, diff --git a/lib/hive/hive_adapters.g.dart b/lib/hive/hive_adapters.g.dart index e3d6e5a6..cb510f4f 100644 --- a/lib/hive/hive_adapters.g.dart +++ b/lib/hive/hive_adapters.g.dart @@ -144,122 +144,6 @@ class PlacedWidgetAdapter extends TypeAdapter { typeId == other.typeId; } -class PlacedAgentAdapter extends TypeAdapter { - @override - final typeId = 2; - - @override - PlacedAgent read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return PlacedAgent( - type: fields[0] as AgentType, - position: fields[4] as Offset, - id: fields[2] as String, - isAlly: fields[1] == null ? true : fields[1] as bool, - lineUpID: fields[5] as String?, - state: fields[6] == null ? AgentState.none : fields[6] as AgentState, - weapon: fields[7] == null ? WeaponType.none : fields[7] as WeaponType, - )..isDeleted = fields[3] as bool; - } - - @override - void write(BinaryWriter writer, PlacedAgent obj) { - writer - ..writeByte(8) - ..writeByte(0) - ..write(obj.type) - ..writeByte(1) - ..write(obj.isAlly) - ..writeByte(2) - ..write(obj.id) - ..writeByte(3) - ..write(obj.isDeleted) - ..writeByte(4) - ..write(obj.position) - ..writeByte(5) - ..write(obj.lineUpID) - ..writeByte(6) - ..write(obj.state) - ..writeByte(7) - ..write(obj.weapon); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is PlacedAgentAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class PlacedAbilityAdapter extends TypeAdapter { - @override - final typeId = 3; - - @override - PlacedAbility read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return PlacedAbility( - data: fields[0] as AbilityInfo, - position: fields[5] as Offset, - id: fields[3] as String, - isAlly: fields[1] == null ? true : fields[1] as bool, - length: fields[6] == null ? 0 : (fields[6] as num).toDouble(), - lineUpID: fields[7] as String?, - rotation: fields[2] == null ? 0 : (fields[2] as num).toDouble(), - visualState: fields[9] == null - ? const AbilityVisualState() - : fields[9] as AbilityVisualState, - armLengthsMeters: (fields[8] as List?)?.cast(), - )..isDeleted = fields[4] as bool; - } - - @override - void write(BinaryWriter writer, PlacedAbility obj) { - writer - ..writeByte(10) - ..writeByte(0) - ..write(obj.data) - ..writeByte(1) - ..write(obj.isAlly) - ..writeByte(2) - ..write(obj.rotation) - ..writeByte(3) - ..write(obj.id) - ..writeByte(4) - ..write(obj.isDeleted) - ..writeByte(5) - ..write(obj.position) - ..writeByte(6) - ..write(obj.length) - ..writeByte(7) - ..write(obj.lineUpID) - ..writeByte(8) - ..write(obj.armLengthsMeters) - ..writeByte(9) - ..write(obj.visualState); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is PlacedAbilityAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - class PlacedTextAdapter extends TypeAdapter { @override final typeId = 4; @@ -333,9 +217,8 @@ class PlacedImageAdapter extends TypeAdapter { fileExtension: fields[8] as String?, sizeVersion: (fields[10] as num?)?.toInt(), tagColorValue: (fields[9] as num?)?.toInt(), - ) - ..link = fields[3] as String - ..isDeleted = fields[5] as bool; + link: fields[3] == null ? '' : fields[3] as String, + )..isDeleted = fields[5] as bool; } @override @@ -962,97 +845,6 @@ class FolderColorAdapter extends TypeAdapter { typeId == other.typeId; } -class StrategyPageAdapter extends TypeAdapter { - @override - final typeId = 20; - - @override - StrategyPage read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return StrategyPage( - id: fields[0] as String, - name: fields[2] as String, - isAutoNamed: fields[14] as bool?, - drawingData: (fields[3] as List).cast(), - agentData: (fields[4] as List).cast(), - abilityData: (fields[5] as List).cast(), - textData: (fields[6] as List).cast(), - imageData: (fields[7] as List).cast(), - utilityData: (fields[8] as List).cast(), - sortIndex: (fields[1] as num).toInt(), - isAttack: fields[9] as bool, - settings: fields[10] as StrategySettings, - lineUpOrigins: fields[15] == null - ? const [] - : (fields[15] as List).cast(), - lineUpLandings: fields[16] == null - ? const [] - : (fields[16] as List).cast(), - lineUpLinks: fields[17] == null - ? const [] - : (fields[17] as List).cast(), - lineUpGroups: fields[12] == null - ? const [] - : (fields[12] as List).cast(), - lineUps: - fields[11] == null ? const [] : (fields[11] as List).cast(), - ); - } - - @override - void write(BinaryWriter writer, StrategyPage obj) { - writer - ..writeByte(17) - ..writeByte(0) - ..write(obj.id) - ..writeByte(1) - ..write(obj.sortIndex) - ..writeByte(2) - ..write(obj.name) - ..writeByte(3) - ..write(obj.drawingData) - ..writeByte(4) - ..write(obj.agentData) - ..writeByte(5) - ..write(obj.abilityData) - ..writeByte(6) - ..write(obj.textData) - ..writeByte(7) - ..write(obj.imageData) - ..writeByte(8) - ..write(obj.utilityData) - ..writeByte(9) - ..write(obj.isAttack) - ..writeByte(10) - ..write(obj.settings) - ..writeByte(11) - ..write(obj.lineUps) - ..writeByte(12) - ..write(obj.lineUpGroups) - ..writeByte(14) - ..write(obj.isAutoNamed) - ..writeByte(15) - ..write(obj.lineUpOrigins) - ..writeByte(16) - ..write(obj.lineUpLandings) - ..writeByte(17) - ..write(obj.lineUpLinks); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StrategyPageAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - class LineUpAdapter extends TypeAdapter { @override final typeId = 21; @@ -1897,3 +1689,44 @@ class WeaponTypeAdapter extends TypeAdapter { runtimeType == other.runtimeType && typeId == other.typeId; } + +class CloudMediaJobStateAdapter extends TypeAdapter { + @override + final typeId = 39; + + @override + CloudMediaJobState read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return CloudMediaJobState.pendingUpload; + case 1: + return CloudMediaJobState.pendingAttach; + case 2: + return CloudMediaJobState.failed; + default: + return CloudMediaJobState.pendingUpload; + } + } + + @override + void write(BinaryWriter writer, CloudMediaJobState obj) { + switch (obj) { + case CloudMediaJobState.pendingUpload: + writer.writeByte(0); + case CloudMediaJobState.pendingAttach: + writer.writeByte(1); + case CloudMediaJobState.failed: + writer.writeByte(2); + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CloudMediaJobStateAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/hive/hive_adapters.g.yaml b/lib/hive/hive_adapters.g.yaml index 218c5438..56148fe9 100644 --- a/lib/hive/hive_adapters.g.yaml +++ b/lib/hive/hive_adapters.g.yaml @@ -1,7 +1,7 @@ # Generated by Hive CE # Manual modifications may be necessary for certain migrations # Check in to version control -nextTypeId: 39 +nextTypeId: 40 types: StrategyData: typeId: 0 @@ -53,53 +53,9 @@ types: index: 1 position: index: 2 - PlacedAgent: - typeId: 2 - nextIndex: 8 - fields: - type: - index: 0 - isAlly: - index: 1 - id: - index: 2 - isDeleted: - index: 3 - position: - index: 4 - lineUpID: - index: 5 - state: - index: 6 - weapon: - index: 7 - PlacedAbility: - typeId: 3 - nextIndex: 10 - fields: - data: - index: 0 - isAlly: - index: 1 - rotation: - index: 2 - id: - index: 3 - isDeleted: - index: 4 - position: - index: 5 - length: - index: 6 - lineUpID: - index: 7 - armLengthsMeters: - index: 8 - visualState: - index: 9 PlacedText: typeId: 4 - nextIndex: 8 + nextIndex: 9 fields: text: index: 0 @@ -345,44 +301,6 @@ types: index: 5 custom: index: 6 - StrategyPage: - typeId: 20 - nextIndex: 18 - fields: - id: - index: 0 - sortIndex: - index: 1 - name: - index: 2 - drawingData: - index: 3 - agentData: - index: 4 - abilityData: - index: 5 - textData: - index: 6 - imageData: - index: 7 - utilityData: - index: 8 - isAttack: - index: 9 - settings: - index: 10 - lineUps: - index: 11 - lineUpGroups: - index: 12 - isAutoNamed: - index: 14 - lineUpOrigins: - index: 15 - lineUpLandings: - index: 16 - lineUpLinks: - index: 17 LineUp: typeId: 21 nextIndex: 6 @@ -657,3 +575,13 @@ types: index: 18 odin: index: 19 + CloudMediaJobState: + typeId: 39 + nextIndex: 3 + fields: + pendingUpload: + index: 0 + pendingAttach: + index: 1 + failed: + index: 2 diff --git a/lib/hive/hive_registrar.g.dart b/lib/hive/hive_registrar.g.dart index 4fda4f8f..188807c9 100644 --- a/lib/hive/hive_registrar.g.dart +++ b/lib/hive/hive_registrar.g.dart @@ -14,6 +14,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(AgentTypeAdapter()); registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); + registerAdapter(CloudMediaJobStateAdapter()); registerAdapter(FolderColorAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(LineUpAdapter()); @@ -26,8 +27,6 @@ extension HiveRegistrar on HiveInterface { registerAdapter(MapThemeProfileAdapter()); registerAdapter(MapValueAdapter()); registerAdapter(OffsetAdapter()); - registerAdapter(PlacedAbilityAdapter()); - registerAdapter(PlacedAgentAdapter()); registerAdapter(PlacedCircleAgentAdapter()); registerAdapter(PlacedImageAdapter()); registerAdapter(PlacedTextAdapter()); @@ -36,7 +35,6 @@ extension HiveRegistrar on HiveInterface { registerAdapter(PlacedWidgetAdapter()); registerAdapter(SimpleImageDataAdapter()); registerAdapter(StrategyDataAdapter()); - registerAdapter(StrategyPageAdapter()); registerAdapter(StrategySettingsAdapter()); registerAdapter(TraversalSpeedProfileAdapter()); registerAdapter(UtilityTypeAdapter()); @@ -52,6 +50,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(AgentTypeAdapter()); registerAdapter(AppPreferencesAdapter()); registerAdapter(BoundingBoxAdapter()); + registerAdapter(CloudMediaJobStateAdapter()); registerAdapter(FolderColorAdapter()); registerAdapter(IconDataAdapter()); registerAdapter(LineUpAdapter()); @@ -64,8 +63,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(MapThemeProfileAdapter()); registerAdapter(MapValueAdapter()); registerAdapter(OffsetAdapter()); - registerAdapter(PlacedAbilityAdapter()); - registerAdapter(PlacedAgentAdapter()); registerAdapter(PlacedCircleAgentAdapter()); registerAdapter(PlacedImageAdapter()); registerAdapter(PlacedTextAdapter()); @@ -74,7 +71,6 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(PlacedWidgetAdapter()); registerAdapter(SimpleImageDataAdapter()); registerAdapter(StrategyDataAdapter()); - registerAdapter(StrategyPageAdapter()); registerAdapter(StrategySettingsAdapter()); registerAdapter(TraversalSpeedProfileAdapter()); registerAdapter(UtilityTypeAdapter()); diff --git a/lib/hive/hive_registration.dart b/lib/hive/hive_registration.dart index 9e9348b9..c8a5d077 100644 --- a/lib/hive/hive_registration.dart +++ b/lib/hive/hive_registration.dart @@ -1,11 +1,28 @@ import 'package:hive_ce/hive.dart'; +import 'package:hive_ce_flutter/adapters.dart' + show ColorAdapter, TimeOfDayAdapter; import 'package:icarus/hive/hive_adapters.dart'; import 'package:icarus/hive/hive_registrar.g.dart'; -const int _folderAdapterTypeId = 17; +const int _colorAdapterTypeId = 200; +const int _timeOfDayAdapterTypeId = 201; void registerIcarusAdapters(HiveInterface hive) { + if (!hive.isAdapterRegistered(_colorAdapterTypeId)) { + hive.registerAdapter(ColorAdapter(typeId: _colorAdapterTypeId)); + } + if (!hive.isAdapterRegistered(_timeOfDayAdapterTypeId)) { + hive.registerAdapter( + const TimeOfDayAdapter(typeId: _timeOfDayAdapterTypeId), + ); + } hive.registerAdapters(); + if (!hive.isAdapterRegistered(placedAgentAdapterTypeId)) { + hive.registerAdapter(PlacedAgentAdapter()); + } + if (!hive.isAdapterRegistered(placedAbilityAdapterTypeId)) { + hive.registerAdapter(PlacedAbilityAdapter()); + } if (!hive.isAdapterRegistered(freeDrawingAdapterTypeId)) { hive.registerAdapter(FreeDrawingAdapter()); } @@ -18,13 +35,30 @@ void registerIcarusAdapters(HiveInterface hive) { if (!hive.isAdapterRegistered(ellipseDrawingAdapterTypeId)) { hive.registerAdapter(EllipseDrawingAdapter()); } - if (!hive.isAdapterRegistered(_folderAdapterTypeId)) { + if (!hive.isAdapterRegistered(folderAdapterTypeId)) { hive.registerAdapter(FolderAdapter()); } + if (!hive.isAdapterRegistered(strategyPageAdapterTypeId)) { + hive.registerAdapter(StrategyPageAdapter()); + } } void registerIcarusIsolatedAdapters(IsolatedHiveInterface hive) { + if (!hive.isAdapterRegistered(_colorAdapterTypeId)) { + hive.registerAdapter(ColorAdapter(typeId: _colorAdapterTypeId)); + } + if (!hive.isAdapterRegistered(_timeOfDayAdapterTypeId)) { + hive.registerAdapter( + const TimeOfDayAdapter(typeId: _timeOfDayAdapterTypeId), + ); + } hive.registerAdapters(); + if (!hive.isAdapterRegistered(placedAgentAdapterTypeId)) { + hive.registerAdapter(PlacedAgentAdapter()); + } + if (!hive.isAdapterRegistered(placedAbilityAdapterTypeId)) { + hive.registerAdapter(PlacedAbilityAdapter()); + } if (!hive.isAdapterRegistered(freeDrawingAdapterTypeId)) { hive.registerAdapter(FreeDrawingAdapter()); } @@ -37,7 +71,10 @@ void registerIcarusIsolatedAdapters(IsolatedHiveInterface hive) { if (!hive.isAdapterRegistered(ellipseDrawingAdapterTypeId)) { hive.registerAdapter(EllipseDrawingAdapter()); } - if (!hive.isAdapterRegistered(_folderAdapterTypeId)) { + if (!hive.isAdapterRegistered(folderAdapterTypeId)) { hive.registerAdapter(FolderAdapter()); } + if (!hive.isAdapterRegistered(strategyPageAdapterTypeId)) { + hive.registerAdapter(StrategyPageAdapter()); + } } diff --git a/lib/interactive_map.dart b/lib/interactive_map.dart index b69d3ea0..fb4b264a 100644 --- a/lib/interactive_map.dart +++ b/lib/interactive_map.dart @@ -2,11 +2,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/canvas_resize_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; @@ -25,6 +27,7 @@ import 'package:icarus/widgets/page_transition_overlay.dart'; import 'package:icarus/widgets/image_drop_target.dart'; import 'package:icarus/widgets/line_up_placer.dart'; import 'package:icarus/widgets/map_svg_color_mapper.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class InteractiveMap extends ConsumerStatefulWidget { @@ -124,6 +127,11 @@ class _InteractiveMapState extends ConsumerState { @override Widget build(BuildContext context) { bool isAttack = ref.watch(mapProvider).isAttack; + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); final transitionPresentation = ref.watch( transitionProvider.select( (state) => ( @@ -133,6 +141,9 @@ class _InteractiveMapState extends ConsumerState { ), ), ); + // A view-only reader has no sidebar, so the map takes its width. + final viewOnly = ref.watch(isViewOnlyStrategyProvider); + final double sideBarWidth = viewOnly ? 0 : Settings.sideBarReservedWidth; final effectivePalette = ref.watch(effectiveMapThemePaletteProvider); final mapColorMapper = MapSvgColorMapper.forPalette(effectivePalette); @@ -152,9 +163,8 @@ class _InteractiveMapState extends ConsumerState { final Size playAreaSize = Size(worldWidth, height); CoordinateSystem(playAreaSize: playAreaSize); final coordinateSystem = CoordinateSystem.instance; - final double viewportWidth = - (constraints.maxWidth - Settings.sideBarReservedWidth) - .clamp(0.0, constraints.maxWidth); + final double viewportWidth = (constraints.maxWidth - sideBarWidth) + .clamp(0.0, constraints.maxWidth); final viewportSize = Size(viewportWidth, height); final dimensionsChanged = _lastViewportSize != viewportSize || _lastPlayAreaSize != playAreaSize; @@ -234,162 +244,193 @@ class _InteractiveMapState extends ConsumerState { coordinateSystem: coordinateSystem, ); }, - child: SizedBox( - width: worldWidth, - height: height, - child: Stack( - clipBehavior: Clip.none, - children: [ - // Dot Grid - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - ref - .read(abilityBarProvider.notifier) - .updateData(null); - }, - child: Padding( - padding: const EdgeInsets.all(4.0), - child: RepaintBoundary(child: DotGrid()), - ), - ), - ), - // Map SVG - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - ref - .read(abilityBarProvider.notifier) - .updateData(null); - }, - child: RepaintBoundary( - child: CanonicalMapArtwork( - map: ref.watch(mapProvider).currentMap, - isAttack: isAttack, - child: SvgPicture.asset( - assetName, - colorMapper: mapColorMapper, - semanticsLabel: 'Map', - fit: BoxFit.contain, - ), + child: EditorCanvasRegion( + child: SizedBox( + width: worldWidth, + height: height, + child: Stack( + clipBehavior: Clip.none, + children: [ + // Dot Grid + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + ref + .read(abilityBarProvider.notifier) + .updateData(null); + }, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: + RepaintBoundary(child: DotGrid()), ), ), ), - ), - if (ref.watch(mapProvider).showSpawnBarrier) + // Map SVG Positioned( left: mapLeft, top: 0, width: mapWidth, height: height, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, - child: SvgPicture.asset( - barrierAssetName, - semanticsLabel: 'Barrier', - fit: BoxFit.contain, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + ref + .read(abilityBarProvider.notifier) + .updateData(null); + }, + child: RepaintBoundary( + child: CanonicalMapArtwork( + map: + ref.watch(mapProvider).currentMap, + isAttack: isAttack, + child: SvgPicture.asset( + assetName, + colorMapper: mapColorMapper, + semanticsLabel: 'Map', + fit: BoxFit.contain, + ), + ), ), ), ), - if (ref.watch(mapProvider).showRegionNames) - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: SvgPicture.asset( - calloutsAssetName, - semanticsLabel: 'Callouts', - fit: BoxFit.contain, + if (ref.watch(mapProvider).showSpawnBarrier) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: SvgPicture.asset( + barrierAssetName, + semanticsLabel: 'Barrier', + fit: BoxFit.contain, + ), + ), ), - ), - if (ref.watch(mapProvider).showUltOrbs) - Positioned( - left: mapLeft, - top: 0, - width: mapWidth, - height: height, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, + if (ref.watch(mapProvider).showRegionNames) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, child: SvgPicture.asset( - ultOrbsAssetName, - semanticsLabel: 'Ult Orbs', + calloutsAssetName, + semanticsLabel: 'Callouts', fit: BoxFit.contain, ), ), - ), - Positioned.fill( - child: transitionPresentation.hideView - ? SizedBox.shrink() - : Opacity( - opacity: ref.watch( - interactionStateProvider) == - InteractionState.lineUpPlacing - ? 0.2 - : 1.0, - child: PlacedWidgetBuilder(), + if (ref.watch(mapProvider).showUltOrbs) + Positioned( + left: mapLeft, + top: 0, + width: mapWidth, + height: height, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: SvgPicture.asset( + ultOrbsAssetName, + semanticsLabel: 'Ult Orbs', + fit: BoxFit.contain, ), - ), - Positioned.fill( - child: transitionPresentation.hideView - ? IgnorePointer( - child: LineUpOverlay(), - ) - : SizedBox.shrink(), - ), - Positioned.fill( - child: transitionPresentation.active - ? RepaintBoundary( - child: PageTransitionOverlay(), - ) - : SizedBox.shrink(), - ), - Positioned.fill( - child: transitionPresentation.hideView && - transitionPresentation.phase == - PageTransitionPhase.preparing - ? TemporaryWidgetBuilder() - : SizedBox.shrink(), - ), - // Painting - Positioned.fill( - // Nested Consumer so the per-frame fade-in - // progress only rebuilds the drawing layer. - child: Consumer( - builder: (context, ref, _) { - final transitionOpacity = ref.watch( - drawingsTransitionOpacityProvider, - ); - final lineUpOpacity = - ref.watch(interactionStateProvider) == - InteractionState.lineUpPlacing - ? 0.2 - : 1.0; - return Opacity( - opacity: - transitionOpacity * lineUpOpacity, - child: Transform.flip( - flipX: !isAttack, - flipY: !isAttack, - child: InteractivePainter()), - ); - }, + ), + ), + Positioned.fill( + child: IgnorePointer( + key: const ValueKey( + 'strategy-canvas-object-editor', + ), + ignoring: !canEditPages, + child: ExcludeFocus( + excluding: !canEditPages, + child: transitionPresentation.hideView + ? SizedBox.shrink() + : Opacity( + opacity: ref.watch( + interactionStateProvider) == + InteractionState + .lineUpPlacing + ? 0.2 + : 1.0, + child: PlacedWidgetBuilder(), + ), + ), + ), + ), + Positioned.fill( + child: transitionPresentation.hideView + ? IgnorePointer( + child: LineUpOverlay(), + ) + : SizedBox.shrink(), + ), + Positioned.fill( + child: transitionPresentation.active + ? RepaintBoundary( + child: PageTransitionOverlay(), + ) + : SizedBox.shrink(), ), - ), - if (ref.watch(interactionStateProvider) == - InteractionState.lineUpPlacing) - const Positioned.fill( - child: LineupPositionWidget(), + Positioned.fill( + child: transitionPresentation.hideView && + transitionPresentation.phase == + PageTransitionPhase.preparing + ? TemporaryWidgetBuilder() + : SizedBox.shrink(), ), - ], + // Painting + Positioned.fill( + // Nested Consumer so the per-frame fade-in + // progress only rebuilds the drawing layer. + child: Consumer( + builder: (context, ref, _) { + final transitionOpacity = ref.watch( + drawingsTransitionOpacityProvider, + ); + final lineUpOpacity = ref.watch( + interactionStateProvider) == + InteractionState.lineUpPlacing + ? 0.2 + : 1.0; + return IgnorePointer( + key: const ValueKey( + 'strategy-canvas-drawing-editor', + ), + ignoring: !canEditPages, + child: Opacity( + opacity: transitionOpacity * + lineUpOpacity, + child: Transform.flip( + flipX: !isAttack, + flipY: !isAttack, + child: InteractivePainter()), + ), + ); + }, + ), + ), + if (ref.watch(interactionStateProvider) == + InteractionState.lineUpPlacing) + Positioned.fill( + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineupPositionWidget(), + ), + ), + // Teammates' cursors ride above everything + // they point at. + Positioned.fill( + child: RemoteCursorsLayer( + coordinateSystem: coordinateSystem, + isAttack: isAttack, + ), + ), + ], + ), ), ), ), @@ -400,15 +441,30 @@ class _InteractiveMapState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - HoveredMapItemNameCard(), - DeleteArea(), + children: [ + const HoveredMapItemNameCard(), + if (!viewOnly) + IgnorePointer( + ignoring: !canEditPages, + child: const DeleteArea(), + ), ], ), ), Align( alignment: Alignment.bottomRight, - child: const LineupControlButtons(), + child: IgnorePointer( + ignoring: !canEditPages, + child: const LineupControlButtons(), + ), + ), + // Watches the pointer without taking it from anything. + Positioned.fill( + child: PresenceCursorReporter( + transformationController: controller, + coordinateSystem: coordinateSystem, + isAttack: isAttack, + ), ), ], ), @@ -416,7 +472,7 @@ class _InteractiveMapState extends ConsumerState { ), ), SizedBox( - width: Settings.sideBarReservedWidth, + width: sideBarWidth, height: height, ), ], diff --git a/lib/main.dart b/lib/main.dart index dc15d62c..55ac4ff9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,19 @@ import 'dart:async'; import 'dart:developer' as developer; -import 'dart:io'; import 'dart:ui' show PlatformDispatcher; +import 'package:app_links/app_links.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:custom_mouse_cursor/custom_mouse_cursor.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, kReleaseMode; import 'package:flutter/material.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/services/deep_link_registrar.dart'; +import 'package:icarus/services/desktop_runtime.dart'; import 'package:icarus/const/app_cursors.dart'; import 'package:icarus/const/custom_icons.dart'; @@ -18,18 +23,33 @@ import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/routes.dart'; import 'package:icarus/const/second_instance_args.dart'; import 'package:icarus/const/settings.dart' show Settings; +import 'package:icarus/config/cloud_build_config.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/map_provider.dart'; -import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/share/current_share_origin.dart'; +import 'package:icarus/share/share_link_format.dart'; +import 'package:icarus/services/auth_callback_uri.dart'; +import 'package:icarus/services/browser_url.dart'; import 'package:icarus/services/app_error_reporter.dart'; -import 'package:icarus/services/desktop_runtime.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; import 'package:icarus/services/discord_presence_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/services/open_cloud_strategy_store.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/startup/cloud_bootstrap.dart'; import 'package:icarus/startup/hive_store_launch.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/folder_navigator.dart'; @@ -44,13 +64,89 @@ import 'package:toastification/toastification.dart'; CustomMouseCursor? staticDrawingCursor; WebViewEnvironment? webViewEnvironment; bool isWebViewInitialized = false; +bool isWebViewWarmupComplete = false; +Future? _webViewEnvironmentWarmupFuture; +final AppLinks _appLinks = AppLinks(); +final StreamController _deepLinkUriController = + StreamController.broadcast(); +StreamSubscription? _deepLinkStreamSub; +final List _bufferedDeepLinks = []; +bool _hasDeepLinkListener = false; + +Future _initializeDeepLinkHandling() async { + if (kIsWeb) { + // A browser has no OS deep links: the page's own URL is the only link + // (app_links' web plugin just echoes it). It matters when it is Supabase + // returning from Discord sign-in, or a /share/ link. + final pageUri = Uri.base; + final authCallback = classifyAuthCallbackUri( + pageUri, + redirectUri: currentAuthRedirectUri(), + ); + if (authCallback != AuthCallback.none || + isIcarusShareUri(pageUri, currentOrigin: currentShareOrigin())) { + _publishDeepLink(pageUri, source: 'web_location'); + } + return; + } + + try { + final initialLink = await _appLinks.getInitialLink(); + if (initialLink != null) { + _publishDeepLink(initialLink, source: 'initial'); + } + } catch (error, stackTrace) { + developer.log( + 'Failed to read initial deep link: $error', + name: 'deep_link', + error: error, + stackTrace: stackTrace, + ); + } + + _deepLinkStreamSub ??= _appLinks.uriLinkStream.listen( + (uri) => _publishDeepLink(uri, source: 'stream'), + onError: (Object error, StackTrace stackTrace) { + developer.log( + 'Deep link stream error: $error', + name: 'deep_link', + error: error, + stackTrace: stackTrace, + ); + }, + ); +} + +void _publishDeepLink(Uri uri, {required String source}) { + final redactedUri = redactDeepLinkUri(uri); + developer.log('Deep link received [$source]: $redactedUri', + name: 'deep_link'); + AppErrorReporter.reportInfo( + 'Deep link received [$source]: $redactedUri', + source: 'deep_link', + ); + if (!_hasDeepLinkListener) { + _bufferedDeepLinks.add(uri); + return; + } + _deepLinkUriController.add(uri); +} + Future main(List args) async { await runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); - appProviderContainer = ProviderContainer(); + + appProviderContainer = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWith( + (ref) => ref.watch(cloudSignOutRequestProvider), + ), + ]); await _initializePersistedDebugLog(); _installGlobalErrorHandlers(); + final cloudBuildConfig = CloudBuildConfig.fromEnvironment( + isReleaseMode: kReleaseMode, + ); final launch = HiveStoreLaunch.parse(args); final PreparedHiveStore? alternateHiveStore; @@ -63,6 +159,9 @@ Future main(List args) async { ); } + await registerDeepLinkProtocol('icarus'); + await _initializeDeepLinkHandling(); + await ensureIcarusSingleInstance( launch.fileOpenArgs, instanceId: alternateHiveStore?.windowsSingleInstanceId ?? @@ -106,27 +205,25 @@ Future main(List args) async { await MapThemeProfilesProvider.bootstrap(); - await StrategyProvider.migrateAllStrategies(); + await StrategyMigrator.migrateLocalLibrary(PlatformPolicy.current); + + // The cloud comes up degraded rather than not at all: a failure here + // must never keep anyone from the library on this device. + appProviderContainer.read(cloudStartupProvider.notifier).state = + await startCloud( + openOutboxes: openCloudOutboxes, + initializeClients: () => initializeCloudClients(cloudBuildConfig), + ); await AnalyticsService.instance.initialize(); // await Hive.box(HiveBoxNames.strategiesBox).clear(); - await _initWebViewEnvironment(); - if (!kIsWeb) { await initializeIcarusDesktopWindow( "Icarus: Valorant Strategies & Line ups ${Settings.versionName}", ); } - - // Ensure WebView2 environment is initialized on Windows before any InAppWebView - // widgets are created. This is especially important in testing/dev where the - // WebView user-data folder and runtime selection can affect behavior. - // if (!kIsWeb && Platform.isWindows) { - // await _initWebViewEnvironment(); - // } - runApp( UncontrolledProviderScope( container: appProviderContainer, @@ -145,6 +242,33 @@ Future main(List args) async { ); } +Future warmUpWebViewEnvironment() { + if (!isWindowsRuntime) { + isWebViewWarmupComplete = true; + return Future.value(); + } + + return _webViewEnvironmentWarmupFuture ??= + _warmUpWebViewEnvironmentInternal(); +} + +Future _warmUpWebViewEnvironmentInternal() async { + try { + await _initWebViewEnvironment(); + } catch (error, stackTrace) { + webViewEnvironment = null; + isWebViewInitialized = false; + AppErrorReporter.reportWarning( + 'WebView failed to initialize. Youtube embeds will be unavailable.', + source: 'main.warmUpWebViewEnvironment', + error: error, + stackTrace: stackTrace, + ); + } finally { + isWebViewWarmupComplete = true; + } +} + void _installGlobalErrorHandlers() { final originalFlutterOnError = FlutterError.onError; @@ -199,23 +323,26 @@ Future _initializePersistedDebugLog() async { } Future _initWebViewEnvironment() async { - if (kIsWeb) return; - if (Platform.isWindows) { + if (isWindowsRuntime) { + if (isWebViewInitialized && webViewEnvironment != null) { + return; + } + final dir = await getApplicationSupportDirectory(); final availableVersion = await WebViewEnvironment.getAvailableVersion(); if (availableVersion == null) { + webViewEnvironment = null; isWebViewInitialized = false; return; } - isWebViewInitialized = true; - webViewEnvironment = await WebViewEnvironment.create( settings: WebViewEnvironmentSettings( userDataFolder: path.join(dir.path, 'webview'), ), ); + isWebViewInitialized = true; } } @@ -229,6 +356,8 @@ class MyApp extends ConsumerStatefulWidget { class _MyAppState extends ConsumerState { StreamSubscription>? _secondInstanceSub; + StreamSubscription? _deepLinkSub; + final Set _processedDeepLinks = {}; late final DiscordPresenceService _discordPresence; ProviderSubscription? _discordStrategySub; ProviderSubscription? _discordMapSub; @@ -239,7 +368,7 @@ class _MyAppState extends ConsumerState { Future _loadFromFilePathWithWarning(String filePath) async { try { - await ref.read(strategyProvider.notifier).loadFromFilePath(filePath); + await StrategyImportExportService(ref).loadFromFilePath(filePath); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( NewerVersionImportException.userMessage, @@ -250,9 +379,62 @@ class _MyAppState extends ConsumerState { } } + Future _handleIncomingArgument( + String argument, { + required String source, + }) async { + final uri = Uri.tryParse(argument); + if (uri != null && + (uri.scheme.toLowerCase() == 'icarus' || + isIcarusShareUri(uri, currentOrigin: currentShareOrigin()))) { + _handleIncomingUri(uri, source: source); + return; + } + + await _loadFromFilePathWithWarning(argument); + } + + void _handleIncomingUri(Uri uri, {required String source}) { + final uriText = uri.toString(); + if (!_processedDeepLinks.add(uriText)) { + developer.log( + 'Ignoring duplicate deep link [$source]: ${redactDeepLinkUri(uri)}', + name: 'deep_link', + ); + return; + } + + final redactedUri = redactDeepLinkUri(uri); + developer.log('Handling deep link [$source]: $redactedUri', + name: 'deep_link'); + AppErrorReporter.reportInfo( + 'Handling deep link [$source]: $redactedUri', + source: 'deep_link', + ); + + unawaited(() async { + final handledAuth = await ref + .read(authProvider.notifier) + .handleAuthCallbackUri(uri, source: source); + if (handledAuth) { + if (kIsWeb) { + replaceBrowserUrl(withoutAuthCallbackParameters(Uri.base)); + } + return; + } + await ref + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(uri, source: source); + }()); + } + @override void initState() { super.initState(); + ref.read(authProvider); + ref.read(strategyOpQueueProvider); + ref.read(cloudMediaUploadQueueProvider); + ref.read(cloudMediaCacheProvider); _discordPresence = DiscordPresenceService(); _discordStrategySub = ref.listenManual( @@ -279,33 +461,59 @@ class _MyAppState extends ConsumerState { unawaited(_syncDiscordPresence()); WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(warmUpWebViewEnvironment()); + + final cloudUnavailableReason = + ref.read(cloudStartupProvider).unavailableReason; + if (cloudUnavailableReason != null) { + AppErrorReporter.reportWarning( + cloudUnavailableReason, + source: 'main.cloudStartup', + promptUser: true, + ); + } + if (widget.data.isEmpty) return; for (final argument in widget.data) { AppErrorReporter.reportInfo( - 'Startup argument: $argument', + 'Startup argument: ${redactLaunchArgument(argument)}', source: 'main.startupArgs', ); + unawaited(_handleIncomingArgument(argument, source: 'startup_args')); } - _loadFromFilePathWithWarning(widget.data.first); }); _secondInstanceSub = secondInstanceArgsController.stream.listen((args) { if (args.isEmpty) return; - _loadFromFilePathWithWarning(args.first); for (final argument in args) { AppErrorReporter.reportInfo( - 'Second-instance argument: $argument', + 'Second-instance argument: ${redactLaunchArgument(argument)}', source: 'main.secondInstanceArgs', ); + unawaited(_handleIncomingArgument(argument, source: 'second_instance')); } }); + + _deepLinkSub = _deepLinkUriController.stream.listen( + (uri) => _handleIncomingUri(uri, source: 'app_links'), + ); + _hasDeepLinkListener = true; + if (_bufferedDeepLinks.isNotEmpty) { + final pendingUris = List.from(_bufferedDeepLinks); + _bufferedDeepLinks.clear(); + for (final uri in pendingUris) { + _deepLinkUriController.add(uri); + } + } } @override void dispose() { _secondInstanceSub?.cancel(); + _deepLinkSub?.cancel(); + _hasDeepLinkListener = false; _discordSyncDebounce?.cancel(); _discordStrategySub?.close(); _discordMapSub?.close(); @@ -347,6 +555,21 @@ class _MyAppState extends ConsumerState { @override Widget build(BuildContext context) { + ref.listen(authProvider, (previous, next) { + bool settledSignedOut(AppAuthState? auth) => + auth != null && !auth.isAuthenticated && !auth.isLoading; + // A held code is redeemed once the cloud is ready, or, once it is + // clear nobody is signed in, opened read-only through the link. + if ((next.isAuthenticated && next.isConvexUserReady) || + (settledSignedOut(next) && !settledSignedOut(previous))) { + unawaited( + ref + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(), + ); + } + }); + return ToastificationWrapper( config: const ToastificationConfig( alignment: Alignment.bottomCenter, @@ -414,12 +637,21 @@ class _MyAppState extends ConsumerState { home: const MyHomePage(), routes: { Routes.folderNavigator: (context) => const FolderNavigator(), - Routes.strategyView: (context) => const StrategyView(), Routes.settings: (context) => const SettingsTab(), }, + onGenerateRoute: (settings) => settings.name == Routes.strategyView + ? StrategyView.restoredRoute( + openCloudStrategyId: + ref.read(openCloudStrategyStoreProvider).read(), + allowsLocalLibrary: + ref.read(platformPolicyProvider).allowsLocalLibrary, + ) + : null, builder: (context, child) { return GlobalShortcuts( - child: MouseNavigation(child: child ?? const SizedBox.shrink()), + child: EditorOperationScope( + child: MouseNavigation(child: child ?? const SizedBox.shrink()), + ), ); }, ), diff --git a/lib/migrations/paranoia_range_migration.dart b/lib/migrations/paranoia_range_migration.dart index 918f8597..5d241e10 100644 --- a/lib/migrations/paranoia_range_migration.dart +++ b/lib/migrations/paranoia_range_migration.dart @@ -55,6 +55,10 @@ abstract final class ParanoiaRangeMigration { return moved ? migrated : pages; } + /// One saved Paranoia on [map], moved as [migratePages] moves it. + static PlacedAbility migrateAbility(PlacedAbility ability, MapValue map) => + _ability(ability, Maps.mapScale[map] ?? 1.0); + static PlacedAbility _ability(PlacedAbility ability, double mapScale) { Offset anchor(Ability data) => data.getAnchorPoint( mapScale: mapScale, diff --git a/lib/providers/ability_provider.dart b/lib/providers/ability_provider.dart index 8d8f07a3..9e647a1c 100644 --- a/lib/providers/ability_provider.dart +++ b/lib/providers/ability_provider.dart @@ -3,27 +3,20 @@ import 'dart:ui' show Offset; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:uuid/uuid.dart'; final abilityProvider = NotifierProvider>(AbilityProvider.new); -class AbilityProviderSnapshot { - final List abilities; - final List poppedAbilities; - - const AbilityProviderSnapshot({ - required this.abilities, - required this.poppedAbilities, - }); -} - class AbilitySnapshot { final String id; final List snapshot; @@ -33,6 +26,7 @@ class AbilitySnapshot { class AbilityProvider extends Notifier> { List poppedAbility = []; + final Map _pendingEditBefore = {}; List snapshots = []; static const _uuid = Uuid(); @@ -45,20 +39,27 @@ class AbilityProvider extends Notifier> { final action = UserAction( type: ActionType.addition, id: placedAbility.id, - group: ActionGroup.ability); + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.ability(placedAbility), + )); ref.read(actionProvider.notifier).addAction(action); state = [...state, placedAbility]; } void removeAbilityAsAction(String id) { - if (!state.any((ability) => ability.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.ability(state[index]), + ), ), ); removeAbility(id); @@ -72,6 +73,7 @@ class AbilityProvider extends Notifier> { if (index < 0) return; final ability = newState[index]; + final before = ActionObjectState.ability(ability); final coordinateSystem = CoordinateSystem.instance; final mapState = ref.read(mapProvider); @@ -94,8 +96,15 @@ class AbilityProvider extends Notifier> { final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.ability); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -126,6 +135,10 @@ class AbilityProvider extends Notifier> { position: position, ); addAbility(duplicatedAbility); + // The drag that made the copy moves the copy: hold it like the source. + ref + .read(editorPointersProvider.notifier) + .holdAlongside(sourceId, duplicatedAbility.id); return duplicatedAbility.id; } @@ -140,16 +153,22 @@ class AbilityProvider extends Notifier> { List? armLengthsMeters, }) { final newState = [...state]; - updateGeometryHistory(index); + final before = _pendingEditBefore.remove(newState[index].id) ?? + ActionObjectState.ability(newState[index]); newState[index].updateGeometry( newRotation: rotation, newLength: length, newArmLengthsMeters: armLengthsMeters, ); final action = UserAction( - type: ActionType.edit, - id: newState[index].id, - group: ActionGroup.ability); + type: ActionType.edit, + id: newState[index].id, + group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } @@ -164,13 +183,17 @@ class AbilityProvider extends Notifier> { return; } - updateGeometryHistory(index); + final before = ActionObjectState.ability(newState[index]); newState[index].updateVisualState(visualState); ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.edit, id: newState[index].id, group: ActionGroup.ability, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.ability(newState[index]), + ), ), ); state = newState; @@ -194,11 +217,9 @@ class AbilityProvider extends Notifier> { } void updateGeometryHistory(int index) { - final newState = [...state]; - - newState[index].updateGeometryHistory(); - - state = newState; + if (index < 0 || index >= state.length) return; + _pendingEditBefore[state[index].id] = + ActionObjectState.ability(state[index]); } // void updateLengthHistory(int index) { @@ -210,25 +231,42 @@ class AbilityProvider extends Notifier> { // } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeAbility(action.id); + return; + case ActionType.deletion: + if (poppedAbility.isEmpty) return; + final newState = [...state]; + newState.add(clonePlacedAbility(poppedAbility.removeLast())); + state = newState; + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeAbility(action.id); + return; case ActionType.deletion: - if (poppedAbility.isEmpty) { - return; - } - - final newState = [...state]; - - newState.add(poppedAbility.removeLast()); - state = newState; + final before = delta.before?.ability; + if (before == null) return; + _upsertAbility(clonePlacedAbility(before)); + return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - - newState[index].undoAction(); - state = newState; + _writeEdit(action.id, delta.undoOnto); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; @@ -236,27 +274,48 @@ class AbilityProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedAbility); - newState.add(poppedAbility.removeAt(index)); - + if (poppedAbility.isEmpty) return; + newState.add(clonePlacedAbility(poppedAbility.removeLast())); + state = newState; + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedAbility); - - poppedAbility.add(newState.removeAt(index)); + final index = PlacedWidget.getIndexByID(action.id, newState); + if (index < 0) return; + poppedAbility.add(clonePlacedAbility(newState.removeAt(index))); + state = newState; + return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); + if (index < 0) return; newState[index].redoAction(); + state = newState; + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = newState; + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.ability; + if (after == null) return; + _upsertAbility(clonePlacedAbility(after)); + return; + case ActionType.deletion: + removeAbility(action.id); + return; + case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void removeAbility(String id) { @@ -265,32 +324,57 @@ class AbilityProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - final ability = newState.removeAt(index); - poppedAbility.add(ability); + final removedAbility = newState.removeAt(index); + poppedAbility.removeWhere((item) => item.id == id); + poppedAbility.add(clonePlacedAbility(removedAbility)); state = newState; } void fromHive(List hiveAbilities) { poppedAbility = []; + _pendingEditBefore.clear(); state = hiveAbilities; } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + void mergeRemote( + List incoming, bool Function(String id) keep) { + state = mergeRemoteItems( + current: state, + incoming: incoming, + idOf: (ability) => ability.id, + keep: keep, + ); + } + void clearAll() { poppedAbility = []; + _pendingEditBefore.clear(); state = []; } - AbilityProviderSnapshot takeSnapshot() { - return AbilityProviderSnapshot( - abilities: [...state], - poppedAbilities: [...poppedAbility], - ); + /// Writes an edit onto the ability as it is now. An ability that is gone + /// (a teammate deleted it) stays gone. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; + _upsertAbility(write(ActionObjectState.ability(state[index])).ability!); } - void restoreSnapshot(AbilityProviderSnapshot snapshot) { - poppedAbility = [...snapshot.poppedAbilities]; - state = [...snapshot.abilities]; + void _upsertAbility(PlacedAbility ability) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(ability.id, newState); + if (index < 0) { + newState.add(ability); + } else { + newState[index] = ability; + } + state = newState; } String toJson() { diff --git a/lib/providers/action_history_models.dart b/lib/providers/action_history_models.dart new file mode 100644 index 00000000..d340a304 --- /dev/null +++ b/lib/providers/action_history_models.dart @@ -0,0 +1,293 @@ +import 'dart:convert'; + +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; + +class ActionObjectState { + final String id; + final ActionObjectKind kind; + final PlacedAgentNode? agent; + final PlacedAbility? ability; + final DrawingElement? drawing; + final PlacedText? text; + final PlacedImage? image; + final PlacedUtility? utility; + final LineUp? lineUp; + + const ActionObjectState._({ + required this.id, + required this.kind, + this.agent, + this.ability, + this.drawing, + this.text, + this.image, + this.utility, + this.lineUp, + }); + + factory ActionObjectState.agent(PlacedAgentNode value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.agent, + agent: clonePlacedAgentNode(value), + ); + + factory ActionObjectState.ability(PlacedAbility value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.ability, + ability: clonePlacedAbility(value), + ); + + factory ActionObjectState.drawing(DrawingElement value) => + ActionObjectState._( + id: value.id, + kind: ActionObjectKind.drawing, + drawing: cloneDrawingElement(value), + ); + + factory ActionObjectState.text(PlacedText value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.text, + text: clonePlacedText(value), + ); + + factory ActionObjectState.image(PlacedImage value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.image, + image: clonePlacedImage(value), + ); + + factory ActionObjectState.utility(PlacedUtility value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.utility, + utility: clonePlacedUtility(value), + ); + + factory ActionObjectState.lineUp(LineUp value) => ActionObjectState._( + id: value.id, + kind: ActionObjectKind.lineUp, + lineUp: cloneLineUp(value), + ); + + ActionObjectState clone() { + return switch (kind) { + ActionObjectKind.agent => ActionObjectState.agent(agent!), + ActionObjectKind.ability => ActionObjectState.ability(ability!), + ActionObjectKind.drawing => ActionObjectState.drawing(drawing!), + ActionObjectKind.text => ActionObjectState.text(text!), + ActionObjectKind.image => ActionObjectState.image(image!), + ActionObjectKind.utility => ActionObjectState.utility(utility!), + ActionObjectKind.lineUp => ActionObjectState.lineUp(lineUp!), + }; + } + + /// The object as plain JSON, the same fields live sync sends. Only kinds + /// that record field edits have one; drawings are only added and removed, + /// and lineups keep their own history. + Map toJson() { + final Object encoded = switch (kind) { + ActionObjectKind.agent => agent!.toJson(), + ActionObjectKind.ability => ability!.toJson(), + ActionObjectKind.text => text!.toJson(), + ActionObjectKind.image => image!.toJson(), + ActionObjectKind.utility => utility!.toJson(), + ActionObjectKind.drawing || + ActionObjectKind.lineUp => + throw UnsupportedError('$kind records no field edits'), + }; + return jsonDecode(jsonEncode(encoded)) as Map; + } + + factory ActionObjectState.fromJson( + ActionObjectKind kind, + Map json, + ) { + return switch (kind) { + ActionObjectKind.agent => + ActionObjectState.agent(PlacedAgentNode.fromJson(json)), + ActionObjectKind.ability => + ActionObjectState.ability(PlacedAbility.fromJson(json)), + ActionObjectKind.text => + ActionObjectState.text(PlacedText.fromJson(json)), + ActionObjectKind.image => + ActionObjectState.image(PlacedImage.fromJson(json)), + ActionObjectKind.utility => + ActionObjectState.utility(PlacedUtility.fromJson(json)), + ActionObjectKind.drawing || + ActionObjectKind.lineUp => + throw UnsupportedError('$kind records no field edits'), + }; + } +} + +/// Fields made of independent settings (an ability's visibility toggles), +/// written setting by setting rather than as one value. +const _fieldsOfIndependentSettings = {'visualState'}; + +/// [onto] with each top-level field that differs between [from] and [to] set +/// to its value in [to] (removed where [to] has none). Fields [from] and [to] +/// agree on keep their value in [onto], so writing one edit leaves everything +/// else changed since, a teammate's edits included, as it is. +Map writeChangedFields({ + required Map to, + required Map from, + required Map onto, +}) { + final written = Map.from(onto); + for (final field in {...to.keys, ...from.keys}) { + if (cloudJsonEquivalent(to[field], from[field])) continue; + final (toValue, fromValue, ontoValue) = + (to[field], from[field], onto[field]); + if (_fieldsOfIndependentSettings.contains(field) && + toValue is Map && + fromValue is Map && + ontoValue is Map) { + written[field] = + writeChangedFields(to: toValue, from: fromValue, onto: ontoValue); + } else if (to.containsKey(field)) { + written[field] = to[field]; + } else { + written.remove(field); + } + } + return written; +} + +enum ActionObjectKind { + agent, + ability, + drawing, + text, + image, + utility, + lineUp, +} + +class ObjectHistoryDelta { + final ActionObjectState? before; + final ActionObjectState? after; + + const ObjectHistoryDelta({ + this.before, + this.after, + }); + + String get id => after?.id ?? before!.id; + + /// The object after undoing this edit on [current]: only the fields the + /// edit changed go back to [before]. + ActionObjectState undoOnto(ActionObjectState current) => + _write(to: before!, from: after!, onto: current); + + /// The object after redoing this edit on [current]: only the fields the + /// edit changed go to [after]. + ActionObjectState redoOnto(ActionObjectState current) => + _write(to: after!, from: before!, onto: current); + + static ActionObjectState _write({ + required ActionObjectState to, + required ActionObjectState from, + required ActionObjectState onto, + }) { + final toJson = to.toJson(); + final written = writeChangedFields( + to: toJson, + from: from.toJson(), + onto: onto.toJson(), + ); + // Nothing else changed the object since, so it is exactly [to]; keep + // that copy rather than rebuilding it from JSON. + if (cloudJsonEquivalent(written, toJson)) return to.clone(); + return ActionObjectState.fromJson(to.kind, written); + } + + ObjectHistoryDelta clone() { + return ObjectHistoryDelta( + before: before?.clone(), + after: after?.clone(), + ); + } +} + +PlacedAgentNode clonePlacedAgentNode(PlacedAgentNode value) { + return switch (value) { + PlacedAgent() => value.copyWith(), + PlacedViewConeAgent() => value.copyWith(), + PlacedCircleAgent() => value.copyWith(), + }; +} + +PlacedAbility clonePlacedAbility(PlacedAbility value) => + value.copyWith()..isDeleted = value.isDeleted; + +PlacedText clonePlacedText(PlacedText value) => value.copyWith( + text: value.text, + isDeleted: value.isDeleted, + )..isDeleted = value.isDeleted; + +PlacedImage clonePlacedImage(PlacedImage value) => + value.copyWith(isDeleted: value.isDeleted)..isDeleted = value.isDeleted; + +PlacedUtility clonePlacedUtility(PlacedUtility value) => + value.copyWith()..isDeleted = value.isDeleted; + +LineUp cloneLineUp(LineUp value) { + return LineUp( + id: value.id, + agent: clonePlacedAgentNode(value.agent) as PlacedAgent, + ability: clonePlacedAbility(value.ability), + youtubeLink: value.youtubeLink, + images: value.images.map((image) => image.copyWith()).toList(), + notes: value.notes, + ); +} + +DrawingElement cloneDrawingElement(DrawingElement value) { + if (value is FreeDrawing) { + return value.copyWith( + listOfPoints: [...value.listOfPoints], + boundingBox: cloneBoundingBox(value.boundingBox), + cachedPolylineLengthUnits: value.cachedPolylineLengthUnits, + ); + } + if (value is Line) { + return value.copyWith( + boundingBox: cloneBoundingBox(value.boundingBox), + ); + } + if (value is RectangleDrawing) { + return RectangleDrawing( + start: value.start, + end: value.end, + color: value.color, + thickness: value.thickness, + boundingBox: cloneBoundingBox(value.boundingBox), + isDotted: value.isDotted, + hasArrow: value.hasArrow, + id: value.id, + ); + } + if (value is EllipseDrawing) { + return EllipseDrawing( + start: value.start, + end: value.end, + color: value.color, + thickness: value.thickness, + boundingBox: cloneBoundingBox(value.boundingBox), + isDotted: value.isDotted, + hasArrow: value.hasArrow, + id: value.id, + ); + } + throw UnsupportedError('Unsupported drawing element: ${value.runtimeType}'); +} + +BoundingBox? cloneBoundingBox(BoundingBox? value) { + if (value == null) { + return null; + } + return BoundingBox(min: value.min, max: value.max); +} diff --git a/lib/providers/action_provider.dart b/lib/providers/action_provider.dart index 8c5775e4..0ebe04b7 100644 --- a/lib/providers/action_provider.dart +++ b/lib/providers/action_provider.dart @@ -1,18 +1,18 @@ -import 'dart:ui'; - import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/const/drawing_element.dart'; import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:uuid/uuid.dart'; @@ -36,65 +36,36 @@ enum ActionType { transaction, } -class TransactionSnapshot { - final List targetGroups; - final BulkActionSnapshot before; - final BulkActionSnapshot after; - - const TransactionSnapshot({ - required this.targetGroups, - required this.before, - required this.after, - }); -} - -class BulkActionSnapshot { - final List targetGroups; - final List actionStateBefore; - final List redoStateBefore; - final AgentProviderSnapshot? agentSnapshot; - final AbilityProviderSnapshot? abilitySnapshot; - final DrawingProviderSnapshot? drawingSnapshot; - final TextProviderSnapshot? textSnapshot; - final PlacedImageProviderSnapshot? imageSnapshot; - final UtilityProviderSnapshot? utilitySnapshot; - final LineUpProviderSnapshot? lineUpSnapshot; - final StrategySettings? strategySettingsSnapshot; - final Map imageSizeSnapshot; - final Map textHeightSnapshot; - - const BulkActionSnapshot({ - required this.targetGroups, - required this.actionStateBefore, - required this.redoStateBefore, - this.agentSnapshot, - this.abilitySnapshot, - this.drawingSnapshot, - this.textSnapshot, - this.imageSnapshot, - this.utilitySnapshot, - this.lineUpSnapshot, - this.strategySettingsSnapshot, - this.imageSizeSnapshot = const {}, - this.textHeightSnapshot = const {}, - }); -} - class UserAction { final ActionGroup group; final String id; final ActionType type; - final BulkActionSnapshot? bulkSnapshot; - final TransactionSnapshot? transactionSnapshot; + final ObjectHistoryDelta? objectDelta; + + /// For a transaction or bulk clear (group [ActionGroup.bulk]): the change + /// to each object, in the order they happened. Undo reverses them last to + /// first and redo replays them, each against the page as it is now, so + /// objects a teammate added or changed since are left alone. + final List changes; UserAction({ required this.type, required this.id, required this.group, - this.bulkSnapshot, - this.transactionSnapshot, + this.objectDelta, + this.changes = const [], }); + UserAction copy() { + return UserAction( + type: type, + id: id, + group: group, + objectDelta: objectDelta?.clone(), + changes: changes.map((change) => change.copy()).toList(), + ); + } + @override String toString() { return """ @@ -120,6 +91,36 @@ class WeaponSelectionAction extends UserAction { final WeaponType before; final WeaponType after; + + // History stores copies, so the copy must stay a weapon action; a plain + // edit would undo through the agent's movement history instead. + @override + WeaponSelectionAction copy() { + return WeaponSelectionAction( + id: id, + group: group, + before: before, + after: after, + ); + } +} + +/// A change to the strategy settings. Undo and redo write only the settings +/// it changed, so a teammate's change to another setting is kept. +class StrategySettingsAction extends UserAction { + StrategySettingsAction({ + required super.id, + required this.before, + required this.after, + }) : super(type: ActionType.edit, group: ActionGroup.strategySettings); + + final StrategySettings before; + final StrategySettings after; + + @override + StrategySettingsAction copy() { + return StrategySettingsAction(id: id, before: before, after: after); + } } class ActionProvider extends Notifier> { @@ -132,13 +133,15 @@ class ActionProvider extends Notifier> { ActionGroup.utility, ActionGroup.lineUp, ]; - static const List _transactionGroups = [ - ..._clearableGroups, - ActionGroup.strategySettings, - ]; static const _uuid = Uuid(); + + /// The most undo steps kept; older ones are dropped. + static const historyLimit = 200; + List poppedItems = []; - bool _recordingDisabled = false; + + /// While a transaction's mutation runs, the changes it records. + List? _transactionChanges; @override List build() { @@ -146,7 +149,9 @@ class ActionProvider extends Notifier> { } void addAction(UserAction action) { - if (_recordingDisabled) { + final transactionChanges = _transactionChanges; + if (transactionChanges != null) { + transactionChanges.add(action.copy()); return; } ref.read(strategyProvider.notifier).setUnsaved(); @@ -156,101 +161,231 @@ class ActionProvider extends Notifier> { .updateData(null); // Make the agent tab disappear after an action } poppedItems = []; - state = [...state, action]; - - // log("\n Current state \n ${state.toString()}"); + final history = [...state, action.copy()]; + state = history.length > historyLimit + ? history.sublist(history.length - historyLimit) + : history; } + /// Redoes the most recent undone step that still changes something. Steps + /// that no longer can (a teammate removed what they act on) are dropped on + /// the way, so one keypress always does something visible while anything + /// is left to redo. Each pass takes a step off the stack, so this ends. void redoAction() { - if (poppedItems.isEmpty) { - // log("Popped list is empty"); + while (poppedItems.isNotEmpty) { + final action = poppedItems.removeLast(); + final undo = _redo(action); + if (undo == null) continue; + state = [...state, undo]; + if (action.group == ActionGroup.bulk) { + ref.read(abilityBarProvider.notifier).updateData(null); + } return; } + } - final poppedAction = poppedItems.last; - if (poppedAction.type == ActionType.bulkDeletion) { - _redoBulkAction(poppedAction); + /// Undoes the most recent step that still changes something, dropping the + /// steps on the way that no longer can, as [redoAction] does. + void undoAction() { + while (state.isNotEmpty) { + final action = state.last; + state = state.sublist(0, state.length - 1); + final redo = _undo(action); + if (redo == null) continue; + poppedItems.add(redo); return; } - if (poppedAction.type == ActionType.transaction) { - _redoTransaction(poppedAction); - return; + } + + /// Undoes [action] against the page as it is now. Returns the entry that + /// redoes exactly what this undo did, or null when it changed nothing. + UserAction? _undo(UserAction action) => _replay(action, undo: true); + + /// Redoes [action] against the page as it is now. Returns the entry that + /// undoes exactly what this redo did, or null when it changed nothing. + UserAction? _redo(UserAction action) => _replay(action, undo: false); + + UserAction? _replay(UserAction action, {required bool undo}) { + if (action.group == ActionGroup.bulk) { + // Undo runs the changes last to first, redo first to last; only the + // ones that changed something are kept, in their original order. + final order = undo ? action.changes.reversed : action.changes; + final replayed = [ + for (final change in order) + if (_replay(change, undo: undo) case final done?) done, + ]; + if (replayed.isEmpty) return null; + return _withChanges( + action, + undo ? replayed.reversed.toList() : replayed, + ); } - switch (poppedAction.group) { - case ActionGroup.agent: - ref.read(agentProvider.notifier).redoAction(poppedAction); - case ActionGroup.ability: - ref.read(abilityProvider.notifier).redoAction(poppedAction); - case ActionGroup.drawing: - ref.read(drawingProvider.notifier).redoAction(poppedAction); - case ActionGroup.text: - ref.read(textProvider.notifier).redoAction(poppedAction); - case ActionGroup.image: - ref.read(placedImageProvider.notifier).redoAction(poppedAction); - case ActionGroup.utility: - ref.read(utilityProvider.notifier).redoAction(poppedAction); - case ActionGroup.lineUp: - ref.read(lineUpProvider.notifier).redoAction(poppedAction); - case ActionGroup.strategySettings: - return; - case ActionGroup.bulk: - return; + if (action is LineUpGraphAction) { + return ref + .read(lineUpProvider.notifier) + .replayGraphAction(action, undo: undo); + } + if (action is LineUpEditAction) { + return ref.read(lineUpProvider.notifier).replayEdit(action, undo: undo); + } + if (action is WeaponSelectionAction) { + return _replayWeapon(action, undo: undo); + } + if (action is StrategySettingsAction) { + return _replaySettings(action, undo: undo); } - final newState = [...state]; - newState.add(poppedItems.removeLast()); + final delta = action.objectDelta; + if (delta != null) { + final current = _currentObjectState( + delta.id, + delta.after?.kind ?? delta.before?.kind, + ); + switch (action.type) { + case ActionType.addition: + case ActionType.deletion: + final removes = (action.type == ActionType.addition) == undo; + if (removes) { + if (current == null) return null; + _apply(action, undo: undo); + // The opposite step puts back the object as it was when taken + // away, a teammate's edits included. + return _holding(action, current); + } + // An object that is already there is left as it is. + if (current != null) return null; + case ActionType.edit: + // An object a teammate deleted stays deleted. + if (current == null) return null; + final written = + undo ? delta.undoOnto(current) : delta.redoOnto(current); + // A teammate already put the fields back where this would. + if (cloudJsonEquivalent(written.toJson(), current.toJson())) { + return null; + } + _apply(action, undo: undo); + return UserAction( + type: ActionType.edit, + id: action.id, + group: action.group, + objectDelta: _between(undo: undo, from: current, to: written), + ); + case ActionType.bulkDeletion: + case ActionType.transaction: + break; + } + } + _apply(action, undo: undo); + return action; + } - ref.read(strategyProvider.notifier).setUnsaved(); + /// The edit for the opposite stack after a replay moved an object from + /// [from] to [to]: undo writes an edit's `before`, redo its `after`, so + /// after an undo the entry's redo returns to [from] and after a redo its + /// undo does. + static ObjectHistoryDelta _between({ + required bool undo, + required ActionObjectState from, + required ActionObjectState to, + }) { + return undo + ? ObjectHistoryDelta(before: to, after: from) + : ObjectHistoryDelta(before: from, after: to); + } - state = newState; - // log("\n Current state \n ${state.toString()}"); + /// Sets the weapon of an agent or lineup origin, unless it is already + /// that weapon or the agent or origin is gone. + UserAction? _replayWeapon( + WeaponSelectionAction action, { + required bool undo, + }) { + final current = action.group == ActionGroup.lineUp + ? ref.read(lineUpProvider).originById(action.id)?.agent.weapon + : _currentObjectState(action.id, ActionObjectKind.agent)?.agent?.weapon; + final target = undo ? action.before : action.after; + if (current == null || current == target) return null; + _apply(action, undo: undo); + return WeaponSelectionAction( + id: action.id, + group: action.group, + before: undo ? target : current, + after: undo ? current : target, + ); } - void undoAction() { - // log("Undo action was triggered"); + /// Writes only the settings [action] changed, unless they already hold + /// the values it would write. + UserAction? _replaySettings( + StrategySettingsAction action, { + required bool undo, + }) { + final current = ref.read(strategySettingsProvider); + final (to, from) = + undo ? (action.before, action.after) : (action.after, action.before); + final written = StrategySettings.fromJson(writeChangedFields( + to: to.toJson(), + from: from.toJson(), + onto: current.toJson(), + )); + if (cloudJsonEquivalent(written.toJson(), current.toJson())) return null; + ref.read(strategySettingsProvider.notifier).fromHive(written); + return StrategySettingsAction( + id: action.id, + before: undo ? written : current, + after: undo ? current : written, + ); + } - if (state.isEmpty) return; - final currentAction = state.last; - if (currentAction.type == ActionType.bulkDeletion) { - _undoBulkAction(currentAction); - return; - } - if (currentAction.type == ActionType.transaction) { - _undoTransaction(currentAction); - return; - } + /// [action], an addition or deletion, holding [object] as the copy to put + /// back. + static UserAction _holding(UserAction action, ActionObjectState object) { + return UserAction( + type: action.type, + id: action.id, + group: action.group, + objectDelta: action.type == ActionType.addition + ? ObjectHistoryDelta(after: object) + : ObjectHistoryDelta(before: object), + ); + } + + static UserAction _withChanges(UserAction action, List changes) { + return UserAction( + type: action.type, + id: action.id, + group: action.group, + changes: changes, + ); + } - switch (currentAction.group) { + void _apply(UserAction action, {required bool undo}) { + switch (action.group) { case ActionGroup.agent: - ref.read(agentProvider.notifier).undoAction(currentAction); + final agents = ref.read(agentProvider.notifier); + undo ? agents.undoAction(action) : agents.redoAction(action); case ActionGroup.ability: - ref.read(abilityProvider.notifier).undoAction(currentAction); + final abilities = ref.read(abilityProvider.notifier); + undo ? abilities.undoAction(action) : abilities.redoAction(action); case ActionGroup.drawing: - ref.read(drawingProvider.notifier).undoAction(currentAction); + final drawings = ref.read(drawingProvider.notifier); + undo ? drawings.undoAction(action) : drawings.redoAction(action); case ActionGroup.text: - ref.read(textProvider.notifier).undoAction(currentAction); + final texts = ref.read(textProvider.notifier); + undo ? texts.undoAction(action) : texts.redoAction(action); case ActionGroup.image: - ref.read(placedImageProvider.notifier).undoAction(currentAction); + final images = ref.read(placedImageProvider.notifier); + undo ? images.undoAction(action) : images.redoAction(action); case ActionGroup.utility: - ref.read(utilityProvider.notifier).undoAction(currentAction); + final utilities = ref.read(utilityProvider.notifier); + undo ? utilities.undoAction(action) : utilities.redoAction(action); case ActionGroup.lineUp: - ref.read(lineUpProvider.notifier).undoAction(currentAction); + final lineUps = ref.read(lineUpProvider.notifier); + undo ? lineUps.undoAction(action) : lineUps.redoAction(action); case ActionGroup.strategySettings: - return; case ActionGroup.bulk: - return; + break; } - // log("Undo action was called"); - final newState = [...state]; - poppedItems.add(newState.removeLast()); - - ref.read(strategyProvider.notifier).setUnsaved(); - - state = newState; - // log("\n Current state \n ${state.toString()}"); - - // log("\n Popped State \n ${poppedItems.toString()}"); } // Hard reset used by strategy/page lifecycle flows. This is not undoable. @@ -264,12 +399,107 @@ class ActionProvider extends Notifier> { ref.read(utilityProvider.notifier).clearAll(); ref.read(lineUpProvider.notifier).clearAll(); - ref.read(imageWidgetSizeProvider.notifier).clearAll(); - ref.read(textWidgetHeightProvider.notifier).clearAll(); - ref.read(strategyProvider.notifier).setUnsaved(); state = []; } + void clearActionHistory() { + poppedItems = []; + state = []; + } + + /// Drops the steps that can no longer change anything after the page was + /// rehydrated. Each stack is followed in the order it would replay from the + /// page as it is now, so a step is kept only if what it acts on will be + /// there when its turn comes: an object a teammate deleted is recoverable + /// for an undo step only through a deletion undo reaches first, and for a + /// redo step only through an addition redo reaches first. + void reconcileHistory() { + state = _stillReplayable(state, undo: true); + poppedItems = _stillReplayable(poppedItems, undo: false); + } + + List _stillReplayable( + List stack, { + required bool undo, + }) { + final page = _PagePresence( + objects: { + ...ref.read(agentProvider).map((agent) => agent.id), + ...ref.read(abilityProvider).map((ability) => ability.id), + ...ref.read(drawingProvider).elements.map((drawing) => drawing.id), + ...ref.read(textProvider).map((text) => text.id), + ...ref.read(placedImageProvider).images.map((image) => image.id), + ...ref.read(utilityProvider).map((utility) => utility.id), + }, + links: { + for (final link in ref.read(lineUpProvider).links) link.id: link, + }, + ); + final kept = [ + for (final action in stack.reversed) + if (_replayableOn(page, action, undo: undo) case final step?) step, + ]; + return kept.reversed.toList(); + } + + /// [action] as far as replaying it on [page] would still change something, + /// with [page] moved on to what that replay leaves; null when it would + /// change nothing. Mirrors [_replay] without touching the canvas. + UserAction? _replayableOn( + _PagePresence page, + UserAction action, { + required bool undo, + }) { + if (action.group == ActionGroup.bulk) { + final order = undo ? action.changes.reversed : action.changes; + final kept = [ + for (final change in order) + if (_replayableOn(page, change, undo: undo) case final step?) step, + ]; + if (kept.isEmpty) return null; + return _withChanges(action, undo ? kept.reversed.toList() : kept); + } + + if (action is LineUpGraphAction) { + final from = undo ? action.after : action.before; + final to = undo ? action.before : action.after; + final kept = to.links.map((link) => link.id).toSet(); + final removing = [ + for (final link in from.links) + if (!kept.contains(link.id) && page.links.containsKey(link.id)) + link.id, + ]; + final inserting = [ + for (final link in to.links) + if (!page.links.containsKey(link.id)) link, + ]; + if (removing.isEmpty && inserting.isEmpty) return null; + removing.forEach(page.links.remove); + for (final link in inserting) { + page.links[link.id] = link; + } + return action.copy(); + } + + final delta = action.objectDelta; + if (delta != null && action.type != ActionType.edit) { + final removes = (action.type == ActionType.addition) == undo; + // Taking away needs the object there; putting back needs it gone. + if (removes != page.objects.contains(delta.id)) return null; + removes ? page.objects.remove(delta.id) : page.objects.add(delta.id); + return action.copy(); + } + + final target = switch (action) { + _ when delta != null => delta.id, + WeaponSelectionAction() => action.id, + LineUpEditAction() => action.targetId, + _ => null, + }; + if (target != null && !page.has(target)) return null; + return action.copy(); + } + void clearAllAsAction() { _performBulkClear(_clearableGroups); } @@ -279,41 +509,44 @@ class ActionProvider extends Notifier> { _performBulkClear([group]); } + /// Runs [mutation] as one undo step made of the per-object changes it + /// records. Changing strategy settings records nothing, so when [groups] + /// names them they are compared before and after instead. void performTransaction({ required List groups, required void Function() mutation, }) { - final targetGroups = []; - for (final group in groups) { - if (_transactionGroups.contains(group) && !targetGroups.contains(group)) { - targetGroups.add(group); - } - } - if (targetGroups.isEmpty) { - mutation(); - return; - } + final settingsBefore = groups.contains(ActionGroup.strategySettings) + ? ref.read(strategySettingsProvider) + : null; - final before = _captureBulkSnapshot(targetGroups); - final previousRecordingState = _recordingDisabled; - _recordingDisabled = true; + final outerChanges = _transactionChanges; + final changes = _transactionChanges = []; try { mutation(); } finally { - _recordingDisabled = previousRecordingState; + _transactionChanges = outerChanges; + } + + if (settingsBefore != null) { + final settingsAfter = ref.read(strategySettingsProvider); + if (!cloudJsonEquivalent( + settingsBefore.toJson(), settingsAfter.toJson())) { + changes.add(StrategySettingsAction( + id: _uuid.v4(), + before: settingsBefore, + after: settingsAfter, + )); + } } - final after = _captureBulkSnapshot(targetGroups); + if (changes.isEmpty) return; addAction( UserAction( type: ActionType.transaction, id: _uuid.v4(), group: ActionGroup.bulk, - transactionSnapshot: TransactionSnapshot( - targetGroups: targetGroups, - before: before, - after: after, - ), + changes: changes, ), ); } @@ -326,127 +559,63 @@ class ActionProvider extends Notifier> { } } - if (targetGroups.isEmpty || !_hasAnyItemsForGroups(targetGroups)) { - return; - } - - final snapshot = _captureBulkSnapshot(targetGroups); - final filteredActions = _filterActionsForGroups( - snapshot.actionStateBefore, - targetGroups, - ); + final changes = [ + for (final group in targetGroups) ..._clearChanges(group), + ]; + if (changes.isEmpty) return; _clearProvidersForGroups(targetGroups); - _clearAncillaryState(snapshot); - - state = filteredActions; addAction( UserAction( type: ActionType.bulkDeletion, id: _uuid.v4(), group: ActionGroup.bulk, - bulkSnapshot: snapshot, + changes: changes, ), ); } - bool _hasAnyItemsForGroups(List groups) { - for (final group in groups) { - switch (group) { - case ActionGroup.agent: - if (ref.read(agentProvider).isNotEmpty) return true; - case ActionGroup.ability: - if (ref.read(abilityProvider).isNotEmpty) return true; - case ActionGroup.drawing: - if (ref.read(drawingProvider).elements.isNotEmpty) return true; - case ActionGroup.text: - if (ref.read(textProvider).isNotEmpty) return true; - case ActionGroup.image: - if (ref.read(placedImageProvider).images.isNotEmpty) return true; - case ActionGroup.utility: - if (ref.read(utilityProvider).isNotEmpty) return true; - case ActionGroup.lineUp: - if (ref.read(lineUpProvider).links.isNotEmpty) return true; - case ActionGroup.strategySettings: - break; - case ActionGroup.bulk: - break; - } + /// The deletions that clear [group]: one per object, topmost first, so + /// undo (last to first) puts them back in their order. Lineups are + /// removed as one graph change. + List _clearChanges(ActionGroup group) { + if (group == ActionGroup.lineUp) { + final graph = ref.read(lineUpProvider).graph; + return [ + if (graph.links.isNotEmpty) + LineUpGraphAction( + type: ActionType.deletion, + id: _uuid.v4(), + before: graph.deepCopy(), + ), + ]; } - return false; - } - - BulkActionSnapshot _captureBulkSnapshot(List groups) { - final imageIds = groups.contains(ActionGroup.image) - ? ref.read(placedImageProvider).images.map((image) => image.id) - : const []; - final textIds = groups.contains(ActionGroup.text) - ? ref.read(textProvider).map((text) => text.id) - : const []; - - return BulkActionSnapshot( - targetGroups: [...groups], - actionStateBefore: [...state], - redoStateBefore: [...poppedItems], - agentSnapshot: groups.contains(ActionGroup.agent) - ? ref.read(agentProvider.notifier).takeSnapshot() - : null, - abilitySnapshot: groups.contains(ActionGroup.ability) - ? ref.read(abilityProvider.notifier).takeSnapshot() - : null, - drawingSnapshot: groups.contains(ActionGroup.drawing) - ? ref.read(drawingProvider.notifier).takeSnapshot() - : null, - textSnapshot: groups.contains(ActionGroup.text) - ? ref.read(textProvider.notifier).takeSnapshot() - : null, - imageSnapshot: groups.contains(ActionGroup.image) - ? ref.read(placedImageProvider.notifier).takeSnapshot() - : null, - utilitySnapshot: groups.contains(ActionGroup.utility) - ? ref.read(utilityProvider.notifier).takeSnapshot() - : null, - lineUpSnapshot: groups.contains(ActionGroup.lineUp) - ? ref.read(lineUpProvider.notifier).takeSnapshot() - : null, - strategySettingsSnapshot: groups.contains(ActionGroup.strategySettings) - ? ref.read(strategySettingsProvider) - : null, - imageSizeSnapshot: ref - .read(imageWidgetSizeProvider.notifier) - .takeSnapshotForIds(imageIds), - textHeightSnapshot: ref - .read(textWidgetHeightProvider.notifier) - .takeSnapshotForIds(textIds), - ); - } - - List _filterActionsForGroups( - List actions, - List targetGroups, - ) { - final groupSet = targetGroups.toSet(); - - return actions - .where((action) => !_actionIntersectsGroups(action, groupSet)) - .toList(); - } - bool _actionIntersectsGroups( - UserAction action, Set targetGroups) { - if (action.group == ActionGroup.bulk) { - final bulkSnapshot = action.bulkSnapshot; - if (bulkSnapshot != null) { - return bulkSnapshot.targetGroups.any(targetGroups.contains); - } - final transactionSnapshot = action.transactionSnapshot; - if (transactionSnapshot != null) { - return transactionSnapshot.targetGroups.any(targetGroups.contains); - } - return false; - } - - return targetGroups.contains(action.group); + final Iterable objects = switch (group) { + ActionGroup.agent => ref.read(agentProvider).map(ActionObjectState.agent), + ActionGroup.ability => + ref.read(abilityProvider).map(ActionObjectState.ability), + ActionGroup.drawing => + ref.read(drawingProvider).elements.map(ActionObjectState.drawing), + ActionGroup.text => ref.read(textProvider).map(ActionObjectState.text), + ActionGroup.image => + ref.read(placedImageProvider).images.map(ActionObjectState.image), + ActionGroup.utility => + ref.read(utilityProvider).map(ActionObjectState.utility), + ActionGroup.lineUp || + ActionGroup.strategySettings || + ActionGroup.bulk => + const [], + }; + return [ + for (final object in objects.toList().reversed) + UserAction( + type: ActionType.deletion, + id: object.id, + group: group, + objectDelta: ObjectHistoryDelta(before: object), + ), + ]; } void _clearProvidersForGroups(List groups) { @@ -474,114 +643,58 @@ class ActionProvider extends Notifier> { } } - void _clearAncillaryState(BulkActionSnapshot snapshot) { - if (snapshot.imageSizeSnapshot.isNotEmpty) { - ref - .read(imageWidgetSizeProvider.notifier) - .clearEntries(snapshot.imageSizeSnapshot.keys); - } - if (snapshot.textHeightSnapshot.isNotEmpty) { - ref - .read(textWidgetHeightProvider.notifier) - .clearEntries(snapshot.textHeightSnapshot.keys); - } - } - - void _restoreBulkSnapshot(BulkActionSnapshot snapshot) { - if (snapshot.agentSnapshot != null) { - ref.read(agentProvider.notifier).restoreSnapshot(snapshot.agentSnapshot!); - } - if (snapshot.abilitySnapshot != null) { - ref - .read(abilityProvider.notifier) - .restoreSnapshot(snapshot.abilitySnapshot!); - } - if (snapshot.drawingSnapshot != null) { - ref - .read(drawingProvider.notifier) - .restoreSnapshot(snapshot.drawingSnapshot!); - } - if (snapshot.textSnapshot != null) { - ref.read(textProvider.notifier).restoreSnapshot(snapshot.textSnapshot!); - } - if (snapshot.imageSnapshot != null) { - ref - .read(placedImageProvider.notifier) - .restoreSnapshot(snapshot.imageSnapshot!); - } - if (snapshot.utilitySnapshot != null) { - ref - .read(utilityProvider.notifier) - .restoreSnapshot(snapshot.utilitySnapshot!); - } - if (snapshot.lineUpSnapshot != null) { - ref - .read(lineUpProvider.notifier) - .restoreSnapshot(snapshot.lineUpSnapshot!); - } - if (snapshot.strategySettingsSnapshot != null) { - ref - .read(strategySettingsProvider.notifier) - .fromHive(snapshot.strategySettingsSnapshot!); - } - - if (snapshot.imageSizeSnapshot.isNotEmpty) { - ref - .read(imageWidgetSizeProvider.notifier) - .restoreSnapshot(snapshot.imageSizeSnapshot); + ActionObjectState? _currentObjectState(String id, ActionObjectKind? kind) { + if (kind == null) { + return null; } - if (snapshot.textHeightSnapshot.isNotEmpty) { - ref - .read(textWidgetHeightProvider.notifier) - .restoreSnapshot(snapshot.textHeightSnapshot); + switch (kind) { + case ActionObjectKind.agent: + final index = PlacedWidget.getIndexByID(id, ref.read(agentProvider)); + if (index < 0) return null; + return ActionObjectState.agent(ref.read(agentProvider)[index]); + case ActionObjectKind.ability: + final index = PlacedWidget.getIndexByID(id, ref.read(abilityProvider)); + if (index < 0) return null; + return ActionObjectState.ability(ref.read(abilityProvider)[index]); + case ActionObjectKind.drawing: + final index = + DrawingElement.getIndexByID(id, ref.read(drawingProvider).elements); + if (index < 0) return null; + return ActionObjectState.drawing( + ref.read(drawingProvider).elements[index]); + case ActionObjectKind.text: + final index = PlacedWidget.getIndexByID(id, ref.read(textProvider)); + if (index < 0) return null; + return ActionObjectState.text(ref.read(textProvider)[index]); + case ActionObjectKind.image: + final images = ref.read(placedImageProvider).images; + final index = PlacedWidget.getIndexByID(id, images); + if (index < 0) return null; + return ActionObjectState.image(images[index]); + case ActionObjectKind.utility: + final index = PlacedWidget.getIndexByID(id, ref.read(utilityProvider)); + if (index < 0) return null; + return ActionObjectState.utility(ref.read(utilityProvider)[index]); + case ActionObjectKind.lineUp: + // Lineup history is graph snapshots keyed by action id; the lineup + // graph never records per-object deltas, so there is nothing to keep. + return null; } } +} - void _undoBulkAction(UserAction action) { - final snapshot = action.bulkSnapshot; - if (snapshot == null) return; - - _restoreBulkSnapshot(snapshot); - poppedItems.add(action); - ref.read(strategyProvider.notifier).setUnsaved(); - state = [...snapshot.actionStateBefore]; - } - - void _redoBulkAction(UserAction action) { - final snapshot = action.bulkSnapshot; - if (snapshot == null) return; - - _clearProvidersForGroups(snapshot.targetGroups); - _clearAncillaryState(snapshot); - - final newState = _filterActionsForGroups(state, snapshot.targetGroups) - ..add(poppedItems.removeLast()); - - ref.read(strategyProvider.notifier).setUnsaved(); - ref.read(abilityBarProvider.notifier).updateData(null); - state = newState; - } - - void _undoTransaction(UserAction action) { - final snapshot = action.transactionSnapshot; - if (snapshot == null) return; - - _restoreBulkSnapshot(snapshot.before); - final newState = [...state]; - poppedItems.add(newState.removeLast()); - ref.read(strategyProvider.notifier).setUnsaved(); - state = newState; - } +/// The canvas objects and lineup links a page holds, followed step by step +/// while history is reconciled. +class _PagePresence { + _PagePresence({required this.objects, required this.links}); - void _redoTransaction(UserAction action) { - final snapshot = action.transactionSnapshot; - if (snapshot == null) return; + final Set objects; + final Map links; - _restoreBulkSnapshot(snapshot.after); - final newState = [...state]; - newState.add(poppedItems.removeLast()); - ref.read(strategyProvider.notifier).setUnsaved(); - ref.read(abilityBarProvider.notifier).updateData(null); - state = newState; - } + /// Whether [id] names an object, a link, or an origin or landing a link + /// joins (they exist only while a link uses them). + bool has(String id) => + objects.contains(id) || + links.containsKey(id) || + links.values.any((link) => link.originId == id || link.landingId == id); } diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index d31d6fd2..674c7c37 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -2,11 +2,14 @@ import 'dart:convert'; import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/weapons.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/utilities.dart'; import 'package:uuid/uuid.dart'; @@ -15,18 +18,9 @@ import 'package:icarus/const/placed_classes.dart'; final agentProvider = NotifierProvider>(AgentProvider.new); -class AgentProviderSnapshot { - final List agents; - final List poppedAgents; - - const AgentProviderSnapshot({ - required this.agents, - required this.poppedAgents, - }); -} - class AgentProvider extends Notifier> { List poppedAgents = []; + final Map _pendingEditBefore = {}; static const _uuid = Uuid(); @override @@ -35,14 +29,18 @@ class AgentProvider extends Notifier> { } void addAgent(PlacedAgentNode placedAgent) { + state = [...state, placedAgent]; + final action = UserAction( type: ActionType.addition, id: placedAgent.id, group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.agent(placedAgent), + ), ); ref.read(actionProvider.notifier).addAction(action); - state = [...state, placedAgent]; } void removeAgent(String id) { @@ -51,19 +49,25 @@ class AgentProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - poppedAgents.add(newState.removeAt(index)); + final removedAgent = newState.removeAt(index); + poppedAgents.removeWhere((agent) => agent.id == id); + poppedAgents.add(clonePlacedAgentNode(removedAgent)); state = newState; } void removeAgentAsAction(String id) { - if (!state.any((agent) => agent.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.agent(state[index]), + ), ), ); removeAgent(id); @@ -73,12 +77,20 @@ class AgentProvider extends Notifier> { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.agent(newState[index]); newState[index].state = newState[index].state == AgentState.dead ? AgentState.none : AgentState.dead; - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; @@ -117,12 +129,20 @@ class AgentProvider extends Notifier> { return; } if (index < 0) return; + final before = ActionObjectState.agent(newState[index]); newState[index].updatePosition(position); final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -143,17 +163,19 @@ class AgentProvider extends Notifier> { final duplicatedAgent = _duplicateNode(sourceAgent, id: _uuid.v4(), position: position); addAgent(duplicatedAgent); + // The drag that made the copy moves the copy: hold it like the source. + ref + .read(editorPointersProvider.notifier) + .holdAlongside(sourceId, duplicatedAgent.id); return duplicatedAgent.id; } void updateViewConeHistory(String id) { - final newState = [...state]; - final index = PlacedWidget.getIndexByID(id, newState); + final index = PlacedWidget.getIndexByID(id, state); if (index < 0) return; - final node = newState[index]; + final node = state[index]; if (node is! PlacedViewConeAgent) return; - node.updateGeometryHistory(); - state = newState; + _pendingEditBefore[id] = ActionObjectState.agent(node); } void updateViewConeGeometry({ @@ -166,9 +188,19 @@ class AgentProvider extends Notifier> { if (index < 0) return; final node = newState[index]; if (node is! PlacedViewConeAgent) return; + final before = + _pendingEditBefore.remove(id) ?? ActionObjectState.agent(node); node.updateGeometry(newRotation: rotation, newLength: length); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(node), + ), + ), ); state = newState; } @@ -185,10 +217,19 @@ class AgentProvider extends Notifier> { return; } + final before = ActionObjectState.agent(node); node.updateGeometryHistory(); node.updateVisionElevation(elevation); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; } @@ -204,18 +245,26 @@ class AgentProvider extends Notifier> { if (index < 0) return; final node = newState[index]; if (node is! PlacedCircleAgent) return; + final before = ActionObjectState.agent(node); final hasChange = node.diameterMeters != diameterMeters || node.colorValue != colorValue || node.opacityPercent != opacityPercent; if (!hasChange) return; - node.updateGeometryHistory(); node.updateGeometry( newDiameterMeters: diameterMeters, newColorValue: colorValue, newOpacityPercent: opacityPercent, ); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(node), + ), + ), ); state = newState; } @@ -232,6 +281,7 @@ class AgentProvider extends Notifier> { if (index < 0) return false; final node = newState[index]; if (node is! PlacedAgent) return false; + final before = ActionObjectState.agent(node); newState[index] = PlacedViewConeAgent( id: node.id, @@ -247,7 +297,15 @@ class AgentProvider extends Notifier> { )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; return true; @@ -259,6 +317,7 @@ class AgentProvider extends Notifier> { if (index < 0) return false; final node = newState[index]; if (node is! PlacedViewConeAgent) return false; + final before = ActionObjectState.agent(node); newState[index] = PlacedAgent( id: node.id, @@ -270,7 +329,15 @@ class AgentProvider extends Notifier> { )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; return true; @@ -287,6 +354,7 @@ class AgentProvider extends Notifier> { if (index < 0) return false; final node = newState[index]; if (node is! PlacedAgent) return false; + final before = ActionObjectState.agent(node); newState[index] = PlacedCircleAgent( id: node.id, @@ -301,7 +369,15 @@ class AgentProvider extends Notifier> { )..isDeleted = node.isDeleted; ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.agent), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.agent, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.agent(newState[index]), + ), + ), ); state = newState; return true; @@ -312,23 +388,41 @@ class AgentProvider extends Notifier> { _applyWeapon(action.id, action.before); return; } + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeAgent(action.id); + return; + case ActionType.deletion: + if (poppedAgents.isEmpty) return; + _upsertAgent(clonePlacedAgentNode(poppedAgents.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeAgent(action.id); return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedAgents); - if (index < 0) { + final before = delta.before?.agent; + if (before == null) { return; } - final newState = [...state]; - - final restoredAgent = poppedAgents.removeAt(index); - newState.add(restoredAgent); - state = newState; + _upsertAgent(clonePlacedAgentNode(before)); return; case ActionType.edit: - undoPosition(action.id); + _writeEdit(action.id, delta.undoOnto); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -336,40 +430,21 @@ class AgentProvider extends Notifier> { } } - void undoPosition(String id) { - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(id, newState); - if (index < 0) return; - - newState[index].undoAction(); - - state = newState; - } - void redoAction(UserAction action) { if (action is WeaponSelectionAction) { _applyWeapon(action.id, action.after); return; } - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedAgents); - if (index < 0) return; - final restoredAgent = poppedAgents.removeAt(index); - newState.add(restoredAgent); - state = newState; + if (poppedAgents.isEmpty) return; + _upsertAgent(clonePlacedAgentNode(poppedAgents.removeLast())); return; - case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - final removedAgent = newState.removeAt(index); - poppedAgents.add(removedAgent); - state = newState; + removeAgent(action.id); return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); @@ -381,7 +456,34 @@ class AgentProvider extends Notifier> { case ActionType.transaction: return; } - } catch (_) {} + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.agent; + if (after == null) return; + _upsertAgent(clonePlacedAgentNode(after)); + return; + case ActionType.deletion: + removeAgent(action.id); + return; + case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } + + /// Writes an edit onto the agent as it is now. An agent that is gone (a + /// teammate deleted it) stays gone. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; + _upsertAgent(write(ActionObjectState.agent(state[index])).agent!); } String toJson() { @@ -398,6 +500,7 @@ class AgentProvider extends Notifier> { void fromHive(List hiveAgents) { poppedAgents = []; + _pendingEditBefore.clear(); state = hiveAgents; } @@ -421,28 +524,35 @@ class AgentProvider extends Notifier> { return output; } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + void mergeRemote( + List incoming, + bool Function(String id) keep, + ) { + state = mergeRemoteItems( + current: state, + incoming: incoming, + idOf: (agent) => agent.id, + keep: keep, + ); + } + void clearAll() { poppedAgents = []; + _pendingEditBefore.clear(); state = []; } - AgentProviderSnapshot takeSnapshot() { - return AgentProviderSnapshot( - agents: - state.map((agent) => agent.snapshotCopy()).toList(), - poppedAgents: poppedAgents - .map((agent) => agent.snapshotCopy()) - .toList(), - ); - } - - void restoreSnapshot(AgentProviderSnapshot snapshot) { - poppedAgents = snapshot.poppedAgents - .map((agent) => agent.snapshotCopy()) - .toList(); - state = snapshot.agents - .map((agent) => agent.snapshotCopy()) - .toList(); + void _upsertAgent(PlacedAgentNode agent) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(agent.id, newState); + if (index < 0) { + newState.add(agent); + } else { + newState[index] = agent; + } + state = newState; } PlacedAgentNode _duplicateNode( diff --git a/lib/providers/auth_provider.dart b/lib/providers/auth_provider.dart new file mode 100644 index 00000000..c14ebc57 --- /dev/null +++ b/lib/providers/auth_provider.dart @@ -0,0 +1,1419 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/app_navigator.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/auth_callback_uri.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +final authProvider = + NotifierProvider(AuthProvider.new); + +enum ConvexAuthStatus { + signedOut, + configuring, + ready, + incident, +} + +const _sensitiveAuthKeys = { + 'access_token', + 'refresh_token', + 'provider_token', + 'provider_refresh_token', + 'code', + 'code_verifier', +}; + +bool isConvexUnauthenticatedError(Object error) { + return isTypedConvexUnauthenticatedError(error); +} + +/// Credentials a deep link can carry besides [_sensitiveAuthKeys]: a legacy +/// share link's `?token=`. (A share `?code=` is already covered.) +const _sensitiveLinkKeys = {..._sensitiveAuthKeys, 'token'}; + +/// [uri] safe to log: sign-in secrets and share codes (query, fragment, and +/// the `/share/` path) replaced with ``. A share code is a +/// credential: anyone holding it can redeem the shared strategy or folder. +String redactDeepLinkUri(Uri uri) { + Map redactParameters(Map params) => { + for (final entry in params.entries) + entry.key: _sensitiveLinkKeys.contains(entry.key.toLowerCase()) + ? '' + : entry.value, + }; + + String? redactFragment(String fragment) { + if (fragment.isEmpty) { + return null; + } + try { + final params = Uri.splitQueryString(fragment); + if (params.isEmpty) { + return ''; + } + final hasSecret = params.keys + .any((key) => _sensitiveLinkKeys.contains(key.toLowerCase())); + // Leave a harmless fragment (a Flutter `#/route`) exactly as it was. + return hasSecret + ? Uri(queryParameters: redactParameters(params)).query + : fragment; + } on FormatException { + return ''; + } + } + + Map? redactQuery() { + if (!uri.hasQuery) { + return null; + } + try { + final params = uri.queryParameters; + return params.isEmpty ? null : redactParameters(params); + } on FormatException { + return const {'': ''}; + } + } + + // Everything after a `share` segment (or in an `icarus://share/…` path) + // is the code. + final List segments; + try { + segments = uri.pathSegments; + } on FormatException { + // A path that does not decode (e.g. `/share/%FF`) cannot be redacted + // piece by piece, so none of it is logged. + return unparseableLinkPlaceholder; + } + var afterShare = + uri.scheme.toLowerCase() == 'icarus' && uri.host.toLowerCase() == 'share'; + final pathSegments = []; + for (final segment in segments) { + pathSegments.add(afterShare ? '' : segment); + afterShare = afterShare || segment.toLowerCase() == 'share'; + } + + return uri + .replace( + pathSegments: segments.isEmpty ? null : pathSegments, + queryParameters: redactQuery(), + fragment: redactFragment(uri.fragment), + ) + .toString(); +} + +/// Logged in place of a link that cannot be parsed or decoded. +const unparseableLinkPlaceholder = ''; + +/// A launch argument safe to log. Anything shaped like a link goes through +/// [redactDeepLinkUri] (or becomes [unparseableLinkPlaceholder] if it does not +/// parse); anything else, such as a file path, is logged as is. +String redactLaunchArgument(String argument) { + final looksLikeLink = argument.contains('://') || + argument.trimLeft().toLowerCase().startsWith('icarus:'); + if (!looksLikeLink) { + return argument; + } + final uri = Uri.tryParse(argument.trim()); + return uri == null ? unparseableLinkPlaceholder : redactDeepLinkUri(uri); +} + +String redactAuthDiagnosticText(Object value) { + var redacted = value.toString(); + + for (final key in _sensitiveAuthKeys) { + final escapedKey = RegExp.escape(key); + final keyValuePattern = RegExp( + "([\"']?$escapedKey[\"']?\\s*(?:=|:)\\s*)([\"']?)" + "([^&#,;\\s}\\]\"']+)([\"']?)", + caseSensitive: false, + ); + redacted = redacted.replaceAllMapped( + keyValuePattern, + (match) => '${match.group(1)}${match.group(2)}' + '${match.group(4)}', + ); + + final encodedKeyValuePattern = RegExp( + '($escapedKey%3D)(.*?)(?=%26|\\s|\$)', + caseSensitive: false, + ); + redacted = redacted.replaceAllMapped( + encodedKeyValuePattern, + (match) => '${match.group(1)}%3Credacted%3E', + ); + } + + return redacted; +} + +class AppAuthState { + const AppAuthState({ + required this.isLoading, + required this.isAuthenticated, + required this.isConvexUserReady, + required this.convexAuthStatus, + required this.user, + this.errorMessage, + this.activeAuthIncidentId, + this.lastAuthIncidentSource, + this.isAuthIncidentPromptOpen = false, + }); + + factory AppAuthState.fromSession( + Session? session, { + bool isLoading = false, + bool isConvexUserReady = false, + ConvexAuthStatus? convexAuthStatus, + String? errorMessage, + int? activeAuthIncidentId, + String? lastAuthIncidentSource, + bool isAuthIncidentPromptOpen = false, + }) { + final status = convexAuthStatus ?? + (session == null + ? ConvexAuthStatus.signedOut + : (isConvexUserReady + ? ConvexAuthStatus.ready + : ConvexAuthStatus.configuring)); + + return AppAuthState( + isLoading: isLoading, + isAuthenticated: session != null, + isConvexUserReady: session != null && isConvexUserReady, + convexAuthStatus: status, + user: session?.user, + errorMessage: errorMessage, + activeAuthIncidentId: activeAuthIncidentId, + lastAuthIncidentSource: lastAuthIncidentSource, + isAuthIncidentPromptOpen: isAuthIncidentPromptOpen, + ); + } + + final bool isLoading; + final bool isAuthenticated; + final bool isConvexUserReady; + final ConvexAuthStatus convexAuthStatus; + final User? user; + final String? errorMessage; + final int? activeAuthIncidentId; + final String? lastAuthIncidentSource; + final bool isAuthIncidentPromptOpen; + + bool get hasActiveAuthIncident => activeAuthIncidentId != null; + + String get displayName { + final metadata = user?.userMetadata ?? const {}; + // Metadata values are user/provider controlled — tolerate non-string + // entries instead of crashing the widget build. + String? stringEntry(String key) { + final value = metadata[key]; + return value is String && value.isNotEmpty ? value : null; + } + + final name = stringEntry('full_name') ?? + stringEntry('name') ?? + stringEntry('user_name') ?? + user?.email; + return (name?.isNotEmpty ?? false) ? name! : 'Discord user'; + } + + String? get avatarUrl { + final metadata = user?.userMetadata ?? const {}; + final value = metadata['avatar_url']; + return value is String && value.isNotEmpty ? value : null; + } + + AppAuthState copyWith({ + bool? isLoading, + bool? isAuthenticated, + bool? isConvexUserReady, + ConvexAuthStatus? convexAuthStatus, + User? user, + String? errorMessage, + bool clearError = false, + int? activeAuthIncidentId, + bool clearAuthIncident = false, + String? lastAuthIncidentSource, + bool clearLastAuthIncidentSource = false, + bool? isAuthIncidentPromptOpen, + }) { + return AppAuthState( + isLoading: isLoading ?? this.isLoading, + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + isConvexUserReady: isConvexUserReady ?? this.isConvexUserReady, + convexAuthStatus: convexAuthStatus ?? this.convexAuthStatus, + user: user ?? this.user, + errorMessage: clearError ? null : (errorMessage ?? this.errorMessage), + activeAuthIncidentId: clearAuthIncident + ? null + : (activeAuthIncidentId ?? this.activeAuthIncidentId), + lastAuthIncidentSource: clearLastAuthIncidentSource + ? null + : (lastAuthIncidentSource ?? this.lastAuthIncidentSource), + isAuthIncidentPromptOpen: + isAuthIncidentPromptOpen ?? this.isAuthIncidentPromptOpen, + ); + } +} + +enum _AuthIncidentAction { + retry, + signOut, + dismiss, +} + +abstract class AuthProviderAuthHandle { + void dispose(); +} + +abstract class AuthProviderConvexApi { + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }); + + Stream get authState; + bool get isAuthenticated; + String? get currentConnectionStateLabel; + Future clearAuth(); + Future reconnect(); + Future ensureCurrentUser(); +} + +abstract class AuthProviderSupabaseApi { + Session? get currentSession; + Stream get onAuthStateChange; + + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }); + + Future signInWithPassword({ + required String email, + required String password, + }); + + Future signUp({ + required String email, + required String password, + }); + + Future signOut(); + Future getSessionFromUrl(Uri uri); + Future refreshSession(); +} + +class _DefaultAuthProviderAuthHandle implements AuthProviderAuthHandle { + _DefaultAuthProviderAuthHandle(this._inner); + + final AuthHandleWrapper _inner; + + @override + void dispose() => _inner.dispose(); +} + +class _DefaultAuthProviderConvexApi implements AuthProviderConvexApi { + const _DefaultAuthProviderConvexApi(); + + ConvexClient get _client => ConvexClient.instance; + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool p1)? onAuthChange, + }) async { + final handle = await _client.setAuthWithRefresh( + fetchToken: fetchToken, + onAuthChange: onAuthChange, + ); + return _DefaultAuthProviderAuthHandle(handle); + } + + @override + Stream get authState => _client.authState; + + @override + bool get isAuthenticated => _client.isAuthenticated; + + @override + String? get currentConnectionStateLabel => + _client.currentConnectionState.name; + + @override + Future clearAuth() => _client.clearAuth(); + + @override + Future reconnect() => _client.reconnect(); + + @override + Future ensureCurrentUser() async { + await ConvexStrategyRepository.fromClient(_client).ensureCurrentUser(); + } +} + +class _DefaultAuthProviderSupabaseApi implements AuthProviderSupabaseApi { + const _DefaultAuthProviderSupabaseApi(); + + SupabaseClient get _client => Supabase.instance.client; + + @override + Session? get currentSession => _client.auth.currentSession; + + @override + Stream get onAuthStateChange => _client.auth.onAuthStateChange; + + @override + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }) { + return _client.auth.signInWithOAuth( + provider, + redirectTo: redirectTo, + authScreenLaunchMode: authScreenLaunchMode, + scopes: scopes, + ); + } + + @override + Future signInWithPassword({ + required String email, + required String password, + }) { + return _client.auth.signInWithPassword(email: email, password: password); + } + + @override + Future signUp({ + required String email, + required String password, + }) { + return _client.auth.signUp(email: email, password: password); + } + + @override + Future signOut() => _client.auth.signOut(); + + @override + Future getSessionFromUrl(Uri uri) => + _client.auth.getSessionFromUrl(uri); + + @override + Future refreshSession() => _client.auth.refreshSession(); +} + +/// Supabase in a run where cloud sync did not start: nobody is signed in, +/// and every sign-in fails with the reason, which the sign-in dialog shows. +class _UnavailableAuthProviderSupabaseApi implements AuthProviderSupabaseApi { + const _UnavailableAuthProviderSupabaseApi(this.reason); + + final String reason; + + Future _refuse() => Future.error(CloudUnavailableException(reason)); + + @override + Session? get currentSession => null; + + @override + Stream get onAuthStateChange => const Stream.empty(); + + @override + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }) => + _refuse(); + + @override + Future signInWithPassword({ + required String email, + required String password, + }) => + _refuse(); + + @override + Future signUp({ + required String email, + required String password, + }) => + _refuse(); + + @override + Future signOut() => _refuse(); + + @override + Future getSessionFromUrl(Uri uri) => _refuse(); + + @override + Future refreshSession() => _refuse(); +} + +/// The redirect this build asks Supabase to return to after Discord sign-in: +/// the `icarus://` deep link on desktop, the page's own origin on web. +Uri currentAuthRedirectUri() { + return kIsWeb ? webAuthRedirectUri(Uri.base) : nativeAuthRedirectUri; +} + +class AuthProvider extends Notifier { + StreamSubscription? _supabaseAuthSub; + AuthProviderAuthHandle? _convexAuthHandle; + + /// Whether this run ever gave Convex a token. Until it has, there is no + /// auth to clear, and clearing it would open the Convex socket for a + /// signed-out user who never asked for the cloud. + bool _convexMayHoldAuth = false; + Future? _inFlightConvexSetup; + bool _queuedConvexSetup = false; + bool _disposed = false; + String? _queuedConvexTrigger; + bool _showingIncidentPrompt = false; + int _incidentCounter = 0; + int _authGeneration = 0; + + /// The session [build] started Convex setup for. + String? _buildSessionFingerprint; + + @visibleForTesting + static AuthProviderSupabaseApi? debugSupabaseApi; + + @visibleForTesting + static AuthProviderConvexApi? debugConvexApi; + + @visibleForTesting + static Duration? debugConvexAuthReadyTimeout; + + late final AuthProviderSupabaseApi _supabaseApi; + late final AuthProviderConvexApi _convexApi; + + @visibleForTesting + static void resetTestOverrides() { + debugSupabaseApi = null; + debugConvexApi = null; + debugConvexAuthReadyTimeout = null; + } + + int _advanceAuthGeneration() { + _authGeneration += 1; + return _authGeneration; + } + + String _sessionFingerprint(Session? session) { + if (session == null) { + return 'signed_out'; + } + + return '${session.user.id}:${session.accessToken}'; + } + + bool _isAuthContextCurrent({ + required int generation, + required String sessionFingerprint, + }) { + return generation == _authGeneration && + _sessionFingerprint(_supabaseApi.currentSession) == sessionFingerprint; + } + + @override + AppAuthState build() { + final cloudUnavailableReason = + ref.read(cloudStartupProvider).unavailableReason; + _supabaseApi = debugSupabaseApi ?? + (cloudUnavailableReason == null + ? const _DefaultAuthProviderSupabaseApi() + : _UnavailableAuthProviderSupabaseApi(cloudUnavailableReason)); + _convexApi = debugConvexApi ?? const _DefaultAuthProviderConvexApi(); + final session = _supabaseApi.currentSession; + final initialGeneration = _advanceAuthGeneration(); + _buildSessionFingerprint = _sessionFingerprint(session); + + _supabaseAuthSub ??= _supabaseApi.onAuthStateChange.listen( + _handleSupabaseAuthStateChange, + onError: _handleSupabaseAuthStreamError, + ); + + ref.onDispose(() { + _supabaseAuthSub?.cancel(); + _convexAuthHandle?.dispose(); + // A setup still running, or queued behind it, belongs to a provider + // that is gone: it must finish without reading or changing anything. + _disposed = true; + _queuedConvexSetup = false; + _advanceAuthGeneration(); + }); + // The server accepts this build again (a rolled-back deploy): the + // setup it refused can now succeed. + ref.listen(clientUpgradeRequiredProvider, (previous, next) { + if (previous == true && + !next && + state.convexAuthStatus == ConvexAuthStatus.incident) { + unawaited(reinitializeConvexAuth(source: 'protocol_accepted')); + } + }); + + Future.microtask(() async { + await _configureConvexAuth( + trigger: 'build', + generation: initialGeneration, + sessionFingerprint: _buildSessionFingerprint, + ); + }); + + return AppAuthState.fromSession( + session, + isConvexUserReady: false, + convexAuthStatus: session == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, + ); + } + + void _handleSupabaseAuthStateChange(AuthState event) { + final currentSession = event.session; + // Supabase replays the session it restored at startup to every new + // listener as `initialSession`. [build] already started Convex setup for + // that session; a second setup would tear down the first one's auth under + // the library's live queries. + if (event.event == AuthChangeEvent.initialSession && + _sessionFingerprint(currentSession) == _buildSessionFingerprint) { + return; + } + + final generation = _advanceAuthGeneration(); + state = AppAuthState.fromSession( + currentSession, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: currentSession == null + ? ConvexAuthStatus.signedOut + : ConvexAuthStatus.configuring, + ); + + if (currentSession == null) { + _clearAuthIncident(); + } + + unawaited( + _configureConvexAuth( + trigger: 'supabase:${event.event}', + generation: generation, + sessionFingerprint: _sessionFingerprint(currentSession), + ), + ); + } + + void _handleSupabaseAuthStreamError(Object error, StackTrace stackTrace) { + log( + 'Supabase auth state stream error: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Auth stream error: $error', + ); + } + + Future signInWithDiscord() async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final launched = await _supabaseApi.signInWithOAuth( + OAuthProvider.discord, + redirectTo: currentAuthRedirectUri().toString(), + authScreenLaunchMode: LaunchMode.externalApplication, + scopes: 'identify email', + ); + + if (!launched) { + throw StateError('Discord OAuth browser launch failed'); + } + } catch (error, stackTrace) { + log( + 'Discord sign-in failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: error is CloudUnavailableException + ? error.message + : 'Discord sign-in failed: $error', + ); + return; + } + + state = state.copyWith(isLoading: false); + } + + Future signInWithEmailPassword({ + required String email, + required String password, + }) async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final response = await _supabaseApi.signInWithPassword( + email: email, + password: password, + ); + + if (response.session == null) { + const message = "Sign in didn't complete. Please try again."; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + + state = state.copyWith(isLoading: false); + return null; + } catch (error, stackTrace) { + log( + 'Email/password sign-in failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + final message = _friendlySignInError(error); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + } + + static String _friendlySignInError(Object error) { + if (error is CloudUnavailableException) return error.message; + final message = error.toString().toLowerCase(); + if (message.contains('invalid login credentials') || + message.contains('invalid_credentials')) { + return 'Incorrect email or password.'; + } + if (message.contains('email not confirmed')) { + return 'Confirm your email first — check your inbox for the ' + 'confirmation link.'; + } + if (message.contains('rate limit') || message.contains('too many')) { + return 'Too many attempts. Wait a moment and try again.'; + } + if (message.contains('socket') || + message.contains('network') || + message.contains('timed out') || + message.contains('failed host lookup')) { + return "Couldn't reach the server. Check your connection and try again."; + } + return 'Sign in failed. Please try again.'; + } + + static String _friendlySignUpError(Object error) { + if (error is CloudUnavailableException) return error.message; + final message = error.toString().toLowerCase(); + if (message.contains('already registered') || + message.contains('already exists')) { + return 'An account with this email already exists. Try signing in ' + 'instead.'; + } + if (message.contains('rate limit') || message.contains('too many')) { + return 'Too many attempts. Wait a moment and try again.'; + } + if (message.contains('socket') || + message.contains('network') || + message.contains('timed out') || + message.contains('failed host lookup')) { + return "Couldn't reach the server. Check your connection and try again."; + } + return 'Sign up failed. Please try again.'; + } + + Future signUpWithEmailPassword({ + required String email, + required String password, + }) async { + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + final response = await _supabaseApi.signUp( + email: email, + password: password, + ); + + if (response.session == null) { + const message = + 'Account created! Check your inbox for the confirmation link, ' + 'then sign in.'; + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + errorMessage: message, + ); + return message; + } + + state = state.copyWith(isLoading: false); + return null; + } catch (error, stackTrace) { + log( + 'Email/password sign-up failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + final message = _friendlySignUpError(error); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: message, + ); + return message; + } + } + + Future signOut() async { + // Invalidate any setup that is currently awaiting a Convex auth handle + // before the Supabase auth-state event has a chance to arrive. Otherwise a + // stale setup can resume during sign-out and surface a false auth incident. + _advanceAuthGeneration(); + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + clearError: true, + clearAuthIncident: true, + clearLastAuthIncidentSource: true, + isAuthIncidentPromptOpen: false, + ); + + try { + await _supabaseApi.signOut(); + _convexAuthHandle?.dispose(); + _convexAuthHandle = null; + await _convexApi.clearAuth(); + state = AppAuthState.fromSession( + null, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + ); + } catch (error, stackTrace) { + log( + 'Sign out failed: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Sign out failed. Please try again.', + ); + return; + } + + try { + Settings.showToast( + message: ref.read(platformPolicyProvider).allowsLocalLibrary + ? 'Signed out. Your local strategies stay on this device.' + : 'Signed out.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } catch (error, stackTrace) { + log( + 'Signed out, but the confirmation toast could not be shown: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + } + + /// Returns true when [uri] was an auth callback, including one rejected for + /// carrying session tokens, so the caller can scrub it from the address bar. + Future handleAuthCallbackUri(Uri uri, {required String source}) async { + switch ( + classifyAuthCallbackUri(uri, redirectUri: currentAuthRedirectUri())) { + case AuthCallback.none: + AppErrorReporter.reportInfo( + 'Deep link was not an auth callback [$source]: ${redactDeepLinkUri(uri)}', + source: 'auth', + ); + return false; + case AuthCallback.injectedTokens: + AppErrorReporter.reportInfo( + 'Rejected auth callback carrying session tokens [$source]: ' + '${redactDeepLinkUri(uri)}', + source: 'auth', + ); + return true; + case AuthCallback.signInResult: + break; + } + + AppErrorReporter.reportInfo( + 'Handling auth callback [$source]: ${redactDeepLinkUri(uri)}', + source: 'auth', + ); + + state = state.copyWith( + isLoading: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + await _supabaseApi.getSessionFromUrl(uri); + state = state.copyWith(isLoading: false); + log( + 'Handled auth callback [$source]: ${redactDeepLinkUri(uri)}', + name: 'auth', + ); + AppErrorReporter.reportInfo( + 'Handled auth callback [$source]', + source: 'auth', + ); + return true; + } catch (error, stackTrace) { + final safeError = redactAuthDiagnosticText(error); + final safeStackTrace = StackTrace.fromString( + redactAuthDiagnosticText(stackTrace), + ); + log( + 'Failed auth callback [$source]: $safeError', + name: 'auth', + error: safeError, + stackTrace: safeStackTrace, + ); + AppErrorReporter.reportError( + 'Failed auth callback [$source]: ${redactDeepLinkUri(uri)}', + source: 'auth', + error: safeError, + stackTrace: safeStackTrace, + ); + state = state.copyWith( + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: 'Failed to complete login. Please try again.', + ); + return true; + } + } + + Future _fetchSupabaseAccessToken() async { + try { + final session = _supabaseApi.currentSession; + if (session == null) { + log( + 'Convex token fetch skipped: no active Supabase session.', + name: 'auth', + ); + return null; + } + + final expiresAt = session.expiresAt; + if (expiresAt != null) { + final expiresAtUtc = DateTime.fromMillisecondsSinceEpoch( + expiresAt * 1000, + isUtc: true, + ); + final shouldRefresh = expiresAtUtc + .isBefore(DateTime.now().toUtc().add(const Duration(minutes: 1))); + + if (shouldRefresh) { + try { + final refreshed = await _supabaseApi.refreshSession(); + final refreshedToken = refreshed.session?.accessToken; + if (refreshedToken != null && refreshedToken.isNotEmpty) { + log( + 'Convex token fetch returning refreshed Supabase token.', + name: 'auth', + ); + return refreshedToken; + } + } catch (error, stackTrace) { + log( + 'Supabase refresh failed while fetching Convex token: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + } + } + + log( + 'Convex token fetch returning current Supabase token ' + '(nonEmpty: ${session.accessToken.isNotEmpty}).', + name: 'auth', + ); + return session.accessToken; + } catch (error, stackTrace) { + log( + 'Failed fetching Supabase access token for Convex: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + Future reinitializeConvexAuth({String source = 'manual'}) async { + await _configureConvexAuth(trigger: 'reinitialize:$source'); + } + + Future reportConvexUnauthenticated({ + required String source, + Object? error, + StackTrace? stackTrace, + }) async { + if (error != null && !isConvexUnauthenticatedError(error)) { + return; + } + + if (_supabaseApi.currentSession == null) { + return; + } + + if (state.activeAuthIncidentId != null) { + return; + } + + final incidentId = ++_incidentCounter; + state = state.copyWith( + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + activeAuthIncidentId: incidentId, + lastAuthIncidentSource: source, + errorMessage: + 'Your cloud session expired. Reconnect to resume syncing, or ' + 'sign out.', + ); + + log( + 'Convex unauthenticated incident #$incidentId from $source: ${error ?? 'no error payload'}', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + + unawaited(_showAuthIncidentPrompt(incidentId)); + } + + Future _configureConvexAuth({ + required String trigger, + int? generation, + String? sessionFingerprint, + }) async { + await Future.value(); + if (_disposed) return; + + final targetGeneration = generation ?? _authGeneration; + final targetFingerprint = + sessionFingerprint ?? _sessionFingerprint(_supabaseApi.currentSession); + + if (_inFlightConvexSetup != null) { + _queuedConvexSetup = true; + _queuedConvexTrigger = trigger; + await _inFlightConvexSetup; + return; + } + + final completer = Completer(); + _inFlightConvexSetup = completer.future; + + try { + await _runConvexAuthSetup( + trigger: trigger, + generation: targetGeneration, + sessionFingerprint: targetFingerprint, + ); + } finally { + completer.complete(); + _inFlightConvexSetup = null; + + if (_queuedConvexSetup && !_disposed) { + _queuedConvexSetup = false; + final queuedTrigger = _queuedConvexTrigger ?? 'queued'; + _queuedConvexTrigger = null; + unawaited(_configureConvexAuth(trigger: queuedTrigger)); + } + } + } + + Duration get _convexAuthReadyTimeout => + debugConvexAuthReadyTimeout ?? const Duration(seconds: 5); + + Future _waitForConvexAuthenticated({ + required String trigger, + required bool? reconnectResult, + }) async { + if (_convexApi.isAuthenticated) { + return 'immediate'; + } + + final authenticated = await _convexApi.authState + .firstWhere( + (isAuthenticated) => isAuthenticated, + ) + .timeout( + _convexAuthReadyTimeout, + onTimeout: () { + final connectionState = _convexApi.currentConnectionStateLabel; + throw TimeoutException( + 'Convex auth did not become ready within ' + '${_convexAuthReadyTimeout.inSeconds} seconds ' + 'for trigger "$trigger" ' + '(reconnectResult: ${reconnectResult?.toString() ?? 'unknown'}, ' + 'isAuthenticated: ${_convexApi.isAuthenticated}, ' + 'connectionState: ${connectionState ?? 'unavailable'}).', + ); + }, + ); + + if (!authenticated) { + throw StateError('Convex auth stream completed without authentication.'); + } + + return 'stream'; + } + + Future _runConvexAuthSetup({ + required String trigger, + required int generation, + required String sessionFingerprint, + }) async { + final session = _supabaseApi.currentSession; + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + + log( + 'Starting Convex auth setup [$trigger] (hasSession: ${session != null})', + name: 'auth', + ); + if (session == null) { + _convexAuthHandle?.dispose(); + _convexAuthHandle = null; + if (_convexMayHoldAuth) { + await _convexApi.clearAuth(); + _convexMayHoldAuth = false; + } + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + _clearAuthIncident(); + state = AppAuthState.fromSession( + null, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + ); + return; + } + + state = state.copyWith( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + clearError: true, + ); + + try { + _convexAuthHandle?.dispose(); + final wasAuthenticatedBeforeSetup = _convexApi.isAuthenticated; + bool? reconnectResult; + _convexMayHoldAuth = true; + final authHandle = await _convexApi.setAuthWithRefresh( + fetchToken: _fetchSupabaseAccessToken, + onAuthChange: (isAuthenticated) { + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + if (isAuthenticated) { + return; + } + if (_supabaseApi.currentSession == null) { + return; + } + if (state.convexAuthStatus == ConvexAuthStatus.configuring) { + return; + } + + unawaited( + reportConvexUnauthenticated( + source: 'convex:onAuthChange', + error: Exception('Convex auth state changed to unauthenticated'), + ), + ); + }, + ); + _convexAuthHandle = authHandle; + + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } + + log( + 'Convex auth handle configured [$trigger] (wasAuthenticatedBeforeSetup: ' + '$wasAuthenticatedBeforeSetup, isAuthenticatedNow: ${_convexApi.isAuthenticated})', + name: 'auth', + ); + try { + reconnectResult = await _convexApi.reconnect(); + log( + 'Convex reconnect attempted [$trigger] (result: $reconnectResult, ' + 'isAuthenticatedAfterReconnect: ${_convexApi.isAuthenticated}, ' + 'connectionState: ' + '${_convexApi.currentConnectionStateLabel ?? 'unavailable'})', + name: 'auth', + ); + } catch (error, stackTrace) { + log( + 'Convex reconnect threw [$trigger]: $error', + name: 'auth', + error: error, + stackTrace: stackTrace, + ); + } + final readinessSource = await _waitForConvexAuthenticated( + trigger: trigger, + reconnectResult: reconnectResult, + ); + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } + log( + 'Convex auth ready [$trigger] via $readinessSource', + name: 'auth', + ); + await _convexApi.ensureCurrentUser(); + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + authHandle.dispose(); + if (identical(_convexAuthHandle, authHandle)) { + _convexAuthHandle = null; + } + return; + } + log( + 'Convex current user ensured [$trigger]', + name: 'auth', + ); + + _clearAuthIncident(); + state = state.copyWith( + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + clearError: true, + ); + } catch (error, stackTrace) { + if (!_isAuthContextCurrent( + generation: generation, + sessionFingerprint: sessionFingerprint, + )) { + return; + } + + AppErrorReporter.reportWarning( + error is TimeoutException + ? 'Convex auth readiness timed out [$trigger]' + : 'Convex auth setup failed [$trigger]', + source: 'auth', + error: redactAuthDiagnosticText(error), + stackTrace: StackTrace.fromString(redactAuthDiagnosticText(stackTrace)), + ); + + if (isConvexUnauthenticatedError(error)) { + await reportConvexUnauthenticated( + source: 'setup:$trigger', + error: error, + stackTrace: stackTrace, + ); + return; + } + + final upgradeRequired = + ref.read(clientUpgradeRequiredProvider.notifier).noteError(error); + state = state.copyWith( + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + errorMessage: upgradeRequired + ? clientUpgradeRequiredMessage() + : "Couldn't connect to cloud sync. Please retry.", + ); + } + } + + void _clearAuthIncident() { + state = state.copyWith( + clearAuthIncident: true, + clearLastAuthIncidentSource: true, + isAuthIncidentPromptOpen: false, + ); + } + + Future _showAuthIncidentPrompt(int incidentId) async { + if (_showingIncidentPrompt) { + return; + } + + final navCtx = appNavigatorKey.currentContext ?? + appNavigatorKey.currentState?.overlay?.context; + if (navCtx == null) { + log( + 'Unable to show Convex auth incident prompt; navigator context unavailable.', + name: 'auth', + ); + return; + } + + _showingIncidentPrompt = true; + state = state.copyWith(isAuthIncidentPromptOpen: true); + final localStrategiesNote = + ref.read(platformPolicyProvider).allowsLocalLibrary + ? ' Local strategies are unaffected.' + : ''; + + try { + final action = await showShadDialog<_AuthIncidentAction>( + context: navCtx, + barrierDismissible: false, + builder: (context) { + return ShadDialog.alert( + title: const Text('Cloud connection lost'), + description: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'Your cloud session is no longer valid, so syncing is ' + 'paused. Reconnect to keep your cloud strategies up to ' + 'date, or sign out.$localStrategiesNote', + ), + ), + actions: [ + ShadButton.ghost( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.dismiss); + }, + child: const Text('Not Now'), + ), + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.signOut); + }, + child: const Text('Sign Out'), + ), + ShadButton( + onPressed: () { + Navigator.of(context).pop(_AuthIncidentAction.retry); + }, + child: const Text('Reconnect'), + ), + ], + ); + }, + ); + + if (state.activeAuthIncidentId != incidentId) { + return; + } + + switch (action) { + case _AuthIncidentAction.retry: + await reinitializeConvexAuth(source: 'incident_prompt_retry'); + break; + case _AuthIncidentAction.signOut: + if (navCtx.mounted) { + await ref.read(guardedSignOutRequestProvider)(navCtx); + } + break; + case _AuthIncidentAction.dismiss: + case null: + break; + } + } finally { + _showingIncidentPrompt = false; + if (state.activeAuthIncidentId == incidentId) { + state = state.copyWith(isAuthIncidentPromptOpen: false); + } + } + } +} diff --git a/lib/providers/collab/active_page_live_sync_models.dart b/lib/providers/collab/active_page_live_sync_models.dart new file mode 100644 index 00000000..bed2115d --- /dev/null +++ b/lib/providers/collab/active_page_live_sync_models.dart @@ -0,0 +1,258 @@ +import 'package:icarus/collab/collab_models.dart'; + +enum ActivePageOverlayEntityType { + pageDescriptor, + pageContent, + element, + lineup +} + +enum EntitySyncKeyKind { + strategy, + pageDescriptor, + pageContent, + element, + lineup +} + +class EntitySyncKey { + const EntitySyncKey._({ + required this.kind, + required this.pageId, + required this.entityId, + }); + + const EntitySyncKey.pageDescriptor(String pageId) + : this._( + kind: EntitySyncKeyKind.pageDescriptor, + pageId: pageId, + entityId: null, + ); + + const EntitySyncKey.pageContent(String pageId) + : this._( + kind: EntitySyncKeyKind.pageContent, + pageId: pageId, + entityId: null, + ); + + const EntitySyncKey.element(String pageId, String elementId) + : this._( + kind: EntitySyncKeyKind.element, + pageId: pageId, + entityId: elementId, + ); + + const EntitySyncKey.lineup(String pageId, String lineupId) + : this._( + kind: EntitySyncKeyKind.lineup, + pageId: pageId, + entityId: lineupId, + ); + + const EntitySyncKey.strategy() + : this._( + kind: EntitySyncKeyKind.strategy, + pageId: null, + entityId: null, + ); + + static String _encodeEntityKeyPart(String value) => + Uri.encodeComponent(value); + + static EntitySyncKey? forStrategyOp(StrategyOp op) { + switch (op.entityType) { + case StrategyOpEntityType.strategy: + return const EntitySyncKey.strategy(); + case StrategyOpEntityType.page: + final pageId = op.entityPublicId ?? op.pagePublicId; + if (pageId == null) { + return null; + } + return EntitySyncKey.pageDescriptor(pageId); + case StrategyOpEntityType.pageContent: + final pageId = op.entityPublicId ?? op.pagePublicId; + if (pageId == null) { + return null; + } + return EntitySyncKey.pageContent(pageId); + case StrategyOpEntityType.element: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return EntitySyncKey.element(op.pagePublicId!, op.entityPublicId!); + case StrategyOpEntityType.lineup: + if (op.pagePublicId == null || op.entityPublicId == null) { + return null; + } + return EntitySyncKey.lineup(op.pagePublicId!, op.entityPublicId!); + } + } + + final EntitySyncKeyKind kind; + final String? pageId; + final String? entityId; + + ActivePageOverlayEntityType? get overlayType { + return switch (kind) { + EntitySyncKeyKind.strategy => null, + EntitySyncKeyKind.pageDescriptor => + ActivePageOverlayEntityType.pageDescriptor, + EntitySyncKeyKind.pageContent => ActivePageOverlayEntityType.pageContent, + EntitySyncKeyKind.element => ActivePageOverlayEntityType.element, + EntitySyncKeyKind.lineup => ActivePageOverlayEntityType.lineup, + }; + } + + @override + bool operator ==(Object other) { + return other is EntitySyncKey && + kind == other.kind && + pageId == other.pageId && + entityId == other.entityId; + } + + @override + int get hashCode => Object.hash(kind, pageId, entityId); + + @override + String toString() { + final encodedPageId = _encodeEntityKeyPart(pageId ?? ''); + final encodedEntityId = _encodeEntityKeyPart(entityId ?? ''); + return switch (kind) { + EntitySyncKeyKind.strategy => 'strategy', + EntitySyncKeyKind.pageDescriptor => 'page:$encodedPageId:descriptor', + EntitySyncKeyKind.pageContent => 'page:$encodedPageId:content', + EntitySyncKeyKind.element => 'element:$encodedPageId:$encodedEntityId', + EntitySyncKeyKind.lineup => 'lineup:$encodedPageId:$encodedEntityId', + }; + } +} + +class ActivePageOverlayEntry { + const ActivePageOverlayEntry({ + required this.entityKey, + required this.entityType, + required this.desiredPayload, + required this.desiredSortIndex, + required this.deletion, + required this.baseRevision, + required this.baseDeleted, + required this.dirtyAt, + }); + + final EntitySyncKey entityKey; + final ActivePageOverlayEntityType entityType; + final Object? desiredPayload; + final int? desiredSortIndex; + final bool deletion; + final int? baseRevision; + final bool baseDeleted; + final DateTime dirtyAt; + + ActivePageOverlayEntry copyWith({ + Object? desiredPayload, + int? desiredSortIndex, + bool? deletion, + int? baseRevision, + bool? baseDeleted, + DateTime? dirtyAt, + }) { + return ActivePageOverlayEntry( + entityKey: entityKey, + entityType: entityType, + desiredPayload: desiredPayload ?? this.desiredPayload, + desiredSortIndex: desiredSortIndex ?? this.desiredSortIndex, + deletion: deletion ?? this.deletion, + baseRevision: baseRevision ?? this.baseRevision, + baseDeleted: baseDeleted ?? this.baseDeleted, + dirtyAt: dirtyAt ?? this.dirtyAt, + ); + } +} + +class ProjectedPageElement { + const ProjectedPageElement({ + required this.publicId, + required this.elementType, + required this.payload, + required this.sortIndex, + }); + + final String publicId; + final String elementType; + final CloudPayload payload; + final int sortIndex; +} + +class ProjectedPageLineup { + const ProjectedPageLineup({ + required this.publicId, + required this.payload, + required this.sortIndex, + }); + + final String publicId; + final CloudPayload payload; + final int sortIndex; +} + +class ActivePageProjectedState { + const ActivePageProjectedState({ + required this.pageId, + required this.pageName, + required this.isAttack, + required this.settingsPayload, + required this.elements, + required this.lineups, + }); + + final String pageId; + final String pageName; + final bool isAttack; + final CloudPayload? settingsPayload; + final List elements; + final List lineups; +} + +class QueuedEntityIntent { + const QueuedEntityIntent({ + required this.entityKey, + required this.pending, + }); + + final EntitySyncKey entityKey; + final PendingOp pending; +} + +class InFlightEntityIntent { + const InFlightEntityIntent({ + required this.entityKey, + required this.pending, + required this.sentAt, + }); + + final EntitySyncKey entityKey; + final PendingOp pending; + final DateTime sentAt; +} + +class AckedEntityIntent { + const AckedEntityIntent({ + required this.entityKey, + required this.op, + required this.ack, + this.restored = false, + }); + + final EntitySyncKey entityKey; + final StrategyOp op; + final OpAck ack; + + /// Whether [op] is work the open canvas did not queue: work recovered from + /// the outbox after a restart, or queued before the canvas was last drawn + /// fresh (on a reopen, or a trip to another strategy, local ones + /// included). The canvas was drawn from the server without it, so it + /// never showed what landed. + final bool restored; +} diff --git a/lib/providers/collab/active_page_live_sync_provider.dart b/lib/providers/collab/active_page_live_sync_provider.dart new file mode 100644 index 00000000..4177adb3 --- /dev/null +++ b/lib/providers/collab/active_page_live_sync_provider.dart @@ -0,0 +1,1422 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:math' show max; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:uuid/uuid.dart'; + +class ActivePageLiveSyncState { + const ActivePageLiveSyncState({ + this.strategyPublicId, + this.activePageId, + this.hydratedPageId, + this.hydratedEntityKeys = const {}, + this.remoteBaseRevisionByEntity = const {}, + this.overlayByEntityKey = const {}, + this.lastAckBatch = const [], + }); + + final String? strategyPublicId; + final String? activePageId; + final String? hydratedPageId; + final Set hydratedEntityKeys; + final Map remoteBaseRevisionByEntity; + final Map overlayByEntityKey; + final List lastAckBatch; + + ActivePageLiveSyncState copyWith({ + String? strategyPublicId, + String? activePageId, + bool clearActivePageId = false, + String? hydratedPageId, + bool clearHydratedPage = false, + Set? hydratedEntityKeys, + Map? remoteBaseRevisionByEntity, + Map? overlayByEntityKey, + List? lastAckBatch, + }) { + return ActivePageLiveSyncState( + strategyPublicId: strategyPublicId ?? this.strategyPublicId, + activePageId: + clearActivePageId ? null : (activePageId ?? this.activePageId), + hydratedPageId: + clearHydratedPage ? null : (hydratedPageId ?? this.hydratedPageId), + hydratedEntityKeys: hydratedEntityKeys ?? this.hydratedEntityKeys, + remoteBaseRevisionByEntity: + remoteBaseRevisionByEntity ?? this.remoteBaseRevisionByEntity, + overlayByEntityKey: overlayByEntityKey ?? this.overlayByEntityKey, + lastAckBatch: lastAckBatch ?? this.lastAckBatch, + ); + } +} + +/// Bumped whenever the lineup group memory learns a group (see +/// [ActivePageLiveSyncNotifier.lineupGroupOf]), so what reads it can read it +/// again. +final lineupGroupMemoryRevisionProvider = StateProvider((ref) => 0); + +final activePageLiveSyncProvider = + NotifierProvider( + ActivePageLiveSyncNotifier.new, +); + +class ActivePageLiveSyncNotifier extends Notifier { + // Live reads can advance while local work blocks rehydration. Outbound diffs + // must stay based on the server state that was actually loaded into canvas. + final Map _hydratedBaseByEntityKey = {}; + final Set _remoteAdoptionPending = {}; + + /// Each element's place in its canvas list right after the page was last + /// hydrated, to tell a local restack from the order hydration drew. + final Map _hydratedPositionByKey = {}; + + /// Items the user holds that the server deleted: still on screen until the + /// hold ends, but no longer ordered against anything on the server. + final Set _heldDeletedKeys = {}; + + /// Per page, the lineup group each lineup and spot was last drawn from or + /// written to (see cloudLineupRows), so a group keeps its id and its + /// lineups while the canvas changes under it. Per page because a page + /// duplicated before cloud sync repeats its lineup ids. Never forgotten: + /// an undo can bring back a lineup long after its row moved on. + final Map> _lineupGroupOfByPage = {}; + + /// Records the groups a canvas of [pageId] about to be drawn from cloud + /// rows uses. + void noteLineupGroups(String pageId, Map groupOf) { + _rememberLineupGroups(_lineupGroupOfByPage[pageId] ??= {}, groupOf); + } + + void _rememberLineupGroups( + Map memory, + Map groupOf, + ) { + var learned = false; + for (final MapEntry(:key, :value) in groupOf.entries) { + if (memory[key] != value) { + memory[key] = value; + learned = true; + } + } + if (learned) ref.read(lineupGroupMemoryRevisionProvider.notifier).state++; + } + + /// The server's version of [key] the canvas was drawn from, which local + /// edits to it were made against: its revision, and its payload (null + /// when it was deleted). Null when the canvas was drawn without it. + ({int revision, Object? payload})? hydratedBase(EntitySyncKey key) { + final base = _hydratedBaseByEntityKey[key]; + if (base == null) return null; + return ( + revision: base.revision, + payload: base.deleted ? null : base.payload + ); + } + + /// The lineup group [itemId] (a lineup, origin or landing) on [pageId] + /// was last in. + String? lineupGroupOf(String pageId, String itemId) => + _lineupGroupOfByPage[pageId]?[itemId]; + + @override + ActivePageLiveSyncState build() { + return const ActivePageLiveSyncState(); + } + + void reset() { + _forgetDrawnState(); + state = const ActivePageLiveSyncState(); + } + + /// Drops the bases and overlays: the canvas is drawn fresh next, from the + /// server, without any work still queued. The queue then marks that + /// work's acks restored. + void _forgetDrawnState() { + _hydratedBaseByEntityKey.clear(); + _remoteAdoptionPending.clear(); + _hydratedPositionByKey.clear(); + _heldDeletedKeys.clear(); + if (ref.exists(strategyOpQueueProvider)) { + ref.read(strategyOpQueueProvider.notifier).forgetCanvasWork(); + } + } + + void setStateForTest(ActivePageLiveSyncState nextState) { + state = nextState; + } + + void setContext({ + required String? strategyPublicId, + required String? activePageId, + }) { + final strategyChanged = strategyPublicId != state.strategyPublicId; + final contextChanged = strategyPublicId != state.strategyPublicId || + activePageId != state.activePageId; + if (strategyChanged) _forgetDrawnState(); + state = state.copyWith( + strategyPublicId: strategyPublicId, + activePageId: activePageId, + clearActivePageId: activePageId == null, + clearHydratedPage: contextChanged, + hydratedEntityKeys: + contextChanged ? const {} : state.hydratedEntityKeys, + remoteBaseRevisionByEntity: strategyPublicId == state.strategyPublicId + ? state.remoteBaseRevisionByEntity + : const {}, + overlayByEntityKey: strategyPublicId == state.strategyPublicId + ? state.overlayByEntityKey + : const {}, + ); + } + + void markPageUnhydrated({ + required String strategyPublicId, + required String pageId, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + state = state.copyWith( + clearHydratedPage: true, + hydratedEntityKeys: const {}, + ); + } + + /// [keepBaseFor] names entities the canvas did not take from [snapshot] + /// (the user is holding them). They keep the base they were drawn from, so + /// an edit the user commits to one is checked against the version they saw. + void markPageHydrated({ + required String strategyPublicId, + required String pageId, + required RemoteEditorSnapshot snapshot, + Set keepBaseFor = const {}, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final matchesPage = snapshot.header.publicId == strategyPublicId && + snapshot.activePage?.page.publicId == pageId; + final remoteEntities = { + if (matchesPage) ..._normalizedRemoteEntities(snapshot, pageId), + }; + _heldDeletedKeys + ..removeWhere((key) => key.pageId == pageId) + ..addAll({ + for (final key in keepBaseFor) + if (remoteEntities[key]?.deleted ?? true) key, + }); + for (final key in keepBaseFor) { + final drawnBase = _hydratedBaseByEntityKey[key]; + if (drawnBase == null) { + remoteEntities.remove(key); + continue; + } + // Its content and revision stay as drawn; its place is where the + // merge put it on screen, the server's. + final serverSortIndex = remoteEntities[key]?.sortIndex; + remoteEntities[key] = _NormalizedEntity( + key: key, + overlayEntityType: drawnBase.overlayEntityType, + payload: drawnBase.payload, + sortIndex: serverSortIndex ?? drawnBase.sortIndex, + revision: drawnBase.revision, + deleted: drawnBase.deleted, + ); + } + _hydratedBaseByEntityKey.removeWhere((key, _) => key.pageId == pageId); + _hydratedBaseByEntityKey.addAll(remoteEntities); + _remoteAdoptionPending.removeWhere((key) => key.pageId == pageId); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + )..removeWhere((key, _) => key.pageId == pageId); + for (final entry in remoteEntities.entries) { + remoteRevisions[entry.key] = entry.value.revision; + } + _hydratedPositionByKey + ..removeWhere((key, _) => key.pageId == pageId) + ..addAll({ + for (final (position, envelope) + in _collectLocalElementEnvelopes().indexed) + EntitySyncKey.element(pageId, envelope.publicId): position, + }); + state = state.copyWith( + hydratedPageId: pageId, + hydratedEntityKeys: _normalizedLocalEntities(pageId).keys.toSet(), + remoteBaseRevisionByEntity: remoteRevisions, + ); + } + + /// Takes [pageId] back as the hydrated page, with the base it was drawn + /// from and the acks recorded since: the canvas never left it, only the + /// live read did, as when a teammate deleted it and it was restored. Its + /// unsent work is diffed against that base again. + void resumePage({ + required String strategyPublicId, + required String pageId, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + state = state.copyWith(hydratedPageId: pageId); + } + + /// The entities of [pageId] whose server copy in [snapshot] differs from the + /// one the canvas last drew. + Set remoteChangesSinceHydration( + RemoteEditorSnapshot snapshot, + String pageId, + ) { + _NormalizedEntity? live(_NormalizedEntity? entity) => + entity == null || entity.deleted ? null : entity; + final remote = _normalizedRemoteEntities(snapshot, pageId); + final keys = { + ...remote.keys, + ..._hydratedBaseByEntityKey.keys.where((key) => key.pageId == pageId), + }; + return { + for (final key in keys) + if (!_sameLiveEntity( + live(remote[key]), + live(_hydratedBaseByEntityKey[key]), + )) + key, + }; + } + + bool _sameLiveEntity(_NormalizedEntity? a, _NormalizedEntity? b) => + a == null ? b == null : b != null && _entitiesEquivalent(a, b); + + /// Drops [pageId]'s overlays that no op in the queue carries any more. + /// + /// An overlay holds local intent until the server has it. Once its op has + /// landed, the server's snapshot is the truth, and it may already hold a + /// teammate's newer change to that entity; painting the overlay would show + /// the older local version and the next sync would write it back. Call this + /// before projecting a page for hydration. Acks usually clear overlays in + /// syncLocalPage, but that skips a page that is being rehydrated or is not + /// the active one. A page only rehydrates once its local edits are queued + /// (the session waits for pending cloud sync), so this never drops unsent + /// work. + void dropSatisfiedOverlays(String pageId) { + final queue = ref.read(strategyOpQueueProvider); + bool isPending(EntitySyncKey key) => + queue.queuedByEntityKey.containsKey(key) || + queue.inFlightByEntityKey.containsKey(key) || + queue.successorByEntityKey.containsKey(key) || + queue.pausedByEntityKey.containsKey(key) || + queue.attentionByEntityKey.containsKey(key); + final overlays = Map.from( + state.overlayByEntityKey, + )..removeWhere((key, _) => key.pageId == pageId && !isPending(key)); + if (overlays.length == state.overlayByEntityKey.length) return; + state = state.copyWith(overlayByEntityKey: overlays); + } + + /// Whether the server lacks work the canvas holds for [pageId]: an entity + /// that differs from the one last drawn from (or accepted by) the server, + /// or an op for the page still in the queue. + bool hasUnsentWork(String pageId) { + final local = _normalizedLocalEntities(pageId); + final keys = { + ...local.keys, + ..._hydratedBaseByEntityKey.keys.where((key) => key.pageId == pageId), + }; + if (keys.any( + (key) => !_entitiesEquivalent(local[key], _hydratedBaseByEntityKey[key]), + )) { + return true; + } + final queue = ref.read(strategyOpQueueProvider); + return [ + queue.queuedByEntityKey, + queue.inFlightByEntityKey, + queue.successorByEntityKey, + queue.pausedByEntityKey, + queue.attentionByEntityKey, + ].any((intents) => intents.keys.any((key) => key.pageId == pageId)); + } + + bool hasOverlayForPage(String pageId) { + return state.overlayByEntityKey.keys.any((key) => key.pageId == pageId); + } + + void recordAckBatch(List intents) { + final overlays = Map.from( + state.overlayByEntityKey, + ); + final remoteRevisions = Map.from( + state.remoteBaseRevisionByEntity, + ); + for (final intent in intents) { + final revision = intent.ack.appliedRevision; + final key = intent.entityKey; + if (revision == null) { + continue; + } + final accepted = _normalizedAcceptedEntity( + key: key, + op: intent.op, + revision: revision, + ); + if (accepted == null) continue; + + // The user's newer edit to it, if any, follows the op onto this + // revision, as the queue's successor does. + final overlay = overlays[key]; + if (overlay != null) { + overlays[key] = overlay.copyWith( + baseRevision: revision, + baseDeleted: accepted.deleted, + ); + } + // The canvas was drawn without an op recovered from the outbox and + // still shows the version before it. Its landing is a server change + // like a teammate's: the next merge draws it, and until then an edit + // to the item is checked against the version on screen, so it + // conflicts instead of replacing the recovered work. + if (intent.restored) continue; + _hydratedBaseByEntityKey[key] = accepted; + remoteRevisions[key] = revision; + } + state = state.copyWith( + overlayByEntityKey: overlays, + remoteBaseRevisionByEntity: remoteRevisions, + lastAckBatch: intents, + ); + } + + _NormalizedEntity? _normalizedAcceptedEntity({ + required EntitySyncKey key, + required StrategyOp op, + required int revision, + }) { + final previous = _hydratedBaseByEntityKey[key]; + return switch (op) { + PagePatchOp(:final payload) => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + // The canvas tracks side only. Rename acks must not replace that + // baseline, or introduce fields the canvas never compares. + payload: { + if (previous != null) ..._decodeObject(previous.payload), + if (payload.containsKey('isAttack')) + 'isAttack': payload['isAttack'], + }, + sortIndex: null, + revision: revision, + deleted: false, + ), + PageContentPatchOp(:final settings) => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: {'settings': settings}, + sortIndex: null, + revision: revision, + deleted: false, + ), + ElementAddOp(:final payload, :final sortIndex) || + ElementPatchOp(:final payload?, :final sortIndex?) => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: payload, + sortIndex: sortIndex, + revision: revision, + deleted: false, + ), + ElementPatchOp(:final payload, :final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: payload ?? previous.payload, + sortIndex: sortIndex ?? previous.sortIndex, + revision: revision, + deleted: false, + ), + ElementReorderOp(:final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: previous.payload, + sortIndex: sortIndex, + revision: revision, + deleted: previous.deleted, + ), + ElementDeleteOp() when previous != null => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: previous.payload, + sortIndex: previous.sortIndex, + revision: revision, + deleted: true, + ), + LineupAddOp(:final payload, :final sortIndex) || + LineupPatchOp(:final payload?, :final sortIndex?) => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: payload, + sortIndex: sortIndex, + revision: revision, + deleted: false, + ), + LineupPatchOp(:final payload, :final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: payload ?? previous.payload, + sortIndex: sortIndex ?? previous.sortIndex, + revision: revision, + deleted: false, + ), + LineupReorderOp(:final sortIndex) when previous != null => + _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: previous.payload, + sortIndex: sortIndex, + revision: revision, + deleted: previous.deleted, + ), + LineupDeleteOp() when previous != null => _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: previous.payload, + sortIndex: previous.sortIndex, + revision: revision, + deleted: true, + ), + _ => null, + }; + } + + /// Stops local projection and reconciliation for explicitly discarded work + /// until the affected page has loaded the authoritative remote snapshot. + void adoptRemoteForEntities( + Set entityKeys, { + String? hydratedPageId, + }) { + if (entityKeys.isEmpty) return; + final overlays = Map.from( + state.overlayByEntityKey, + ); + for (final key in entityKeys) { + overlays.remove(key); + if (key.pageId != null && key.pageId != hydratedPageId) { + _remoteAdoptionPending.add(key); + } else { + _remoteAdoptionPending.remove(key); + } + } + state = state.copyWith(overlayByEntityKey: overlays); + } + + Map? syncLocalPage({ + required String strategyPublicId, + required String pageId, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + if (state.hydratedPageId != pageId) { + _debugLog('sync.skip page=$pageId reason=page_not_hydrated'); + return null; + } + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final remotePage = snapshot?.activePage; + if (snapshot == null || + snapshot.header.publicId != strategyPublicId || + remotePage == null || + remotePage.page.publicId != pageId) { + _debugLog( + 'sync.skip page=$pageId reason=missing_matching_remote_base', + ); + return null; + } + + final queueState = ref.read(strategyOpQueueProvider); + final remoteEntities = _normalizedRemoteEntities(snapshot, pageId); + final localEntities = _normalizedLocalEntities(pageId); + + final pageKeys = { + ...remoteEntities.keys, + ...localEntities.keys, + ..._hydratedBaseByEntityKey.keys.where((key) => key.pageId == pageId), + ...state.overlayByEntityKey.keys.where((key) => key.pageId == pageId), + ...queueState.queuedByEntityKey.keys.where((key) => key.pageId == pageId), + ...queueState.inFlightByEntityKey.keys + .where((key) => key.pageId == pageId), + ...queueState.successorByEntityKey.keys + .where((key) => key.pageId == pageId), + ..._remoteAdoptionPending.where((key) => key.pageId == pageId), + }; + + final nextOverlay = Map.from( + state.overlayByEntityKey, + ); + final retainedDesiredOps = {}; + final settledAttention = {}; + + for (final key in pageKeys) { + if (_remoteAdoptionPending.contains(key)) { + nextOverlay.remove(key); + _debugLog('overlay.remove $key reason=adopting_remote'); + continue; + } + final remote = remoteEntities[key]; + final local = localEntities[key]; + final hydratedBase = _hydratedBaseByEntityKey[key]; + final hasQueued = queueState.queuedByEntityKey.containsKey(key); + final hasInFlight = queueState.inFlightByEntityKey.containsKey(key); + final hasSuccessor = queueState.successorByEntityKey.containsKey(key); + final existingOverlay = state.overlayByEntityKey[key]; + final retainedOp = queueState.successorByEntityKey[key]?.pending.op ?? + queueState.inFlightByEntityKey[key]?.pending.op ?? + queueState.queuedByEntityKey[key]?.pending.op; + + final shouldPreserveTouched = hasQueued || hasInFlight || hasSuccessor; + final matchesRemote = _entitiesEquivalent(local, remote); + final matchesHydratedBase = _entitiesEquivalent(local, hydratedBase); + // A restored queue entry has no in-memory overlay. If the canvas still + // matches its hydrated base, the durable op is the only local intent and + // must remain desired until it lands or the user changes that entity, + // whether it is queued or already sent: the version on screen is older + // than it, not a change to send after it. + if (existingOverlay == null && + retainedOp != null && + matchesHydratedBase) { + retainedDesiredOps[key] = retainedOp; + _debugLog('overlay.keep $key reason=durable_queue_only'); + continue; + } + + // The user deleted, on the canvas, an item whose change the server + // refused because a teammate deleted it: both sides have it deleted, + // so the refused change has nothing left to keep, and Keep mine must + // not bring the item back. + if (local == null && + existingOverlay != null && + (remote == null || remote.deleted) && + queueState.attentionByEntityKey.containsKey(key) && + ref.read(strategyOpQueueProvider.notifier).refusedAsDeleted(key)) { + settledAttention.add(key); + } + + if (matchesHydratedBase && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=unchanged_since_hydration'); + } + continue; + } + + if (matchesRemote && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=matched_remote'); + } + continue; + } + + if (local == null && remote == null && !shouldPreserveTouched) { + if (nextOverlay.remove(key) != null) { + _debugLog('overlay.remove $key reason=missing_local_and_remote'); + } + continue; + } + + if (matchesRemote && shouldPreserveTouched && local != null) { + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, + ); + nextOverlay[key] = overlay; + _debugLog('overlay.keep $key reason=pending_reconciliation'); + continue; + } + + if (matchesRemote && existingOverlay != null && !shouldPreserveTouched) { + nextOverlay.remove(key); + _debugLog('overlay.remove $key reason=stale_overlay_cleared'); + continue; + } + + if (local == null) { + if (hydratedBase == null && + existingOverlay == null && + !shouldPreserveTouched) { + _debugLog( + 'overlay.skip $key reason=not_in_hydrated_base', + ); + continue; + } + final entityType = existingOverlay?.entityType ?? + hydratedBase?.overlayEntityType ?? + remote?.overlayEntityType ?? + key.overlayType; + if (entityType == null) { + _debugLog('overlay.skip $key reason=unsupported_entity_key'); + continue; + } + final overlay = ActivePageOverlayEntry( + entityKey: key, + entityType: entityType, + desiredPayload: null, + desiredSortIndex: null, + deletion: true, + baseRevision: existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, + dirtyAt: DateTime.now(), + ); + nextOverlay[key] = overlay; + _debugLog('overlay.upsert $key deletion=true'); + continue; + } + + final overlay = _overlayFromDesiredEntity( + key: key, + desired: local, + hydratedBase: hydratedBase, + existingOverlay: existingOverlay, + ); + nextOverlay[key] = overlay; + _debugLog( + 'overlay.upsert $key deletion=false baseRevision=${overlay.baseRevision}', + ); + } + + if (settledAttention.isNotEmpty) { + // A refusal still waiting to be announced is moot too, as when the + // user chooses Use cloud. + for (final key in settledAttention) { + final refused = queueState.attentionByEntityKey[key]!.pending.op.opId; + ref.read(strategyConflictProvider.notifier).clear(refused); + } + unawaited( + ref + .read(strategyOpQueueProvider.notifier) + .settleAttention(settledAttention), + ); + } + + final desiredOpsByEntityKey = { + ...retainedDesiredOps, + }; + for (final entry in nextOverlay.entries) { + final key = entry.key; + if (key.pageId != pageId) { + continue; + } + final remote = remoteEntities[key]; + final overlay = entry.value; + if (_overlayMatchesRemote(overlay, remote) && + !_needsSuccessor( + pageId: pageId, + key: key, + overlay: overlay, + queueState: queueState, + )) { + continue; + } + final op = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); + if (op != null) { + desiredOpsByEntityKey[key] = op; + } + } + + state = state.copyWith( + strategyPublicId: strategyPublicId, + activePageId: pageId, + overlayByEntityKey: nextOverlay, + ); + + return desiredOpsByEntityKey; + } + + ActivePageProjectedState? projectPageState({ + required String strategyPublicId, + required String pageId, + Set excludedOverlays = const {}, + }) { + setContext(strategyPublicId: strategyPublicId, activePageId: pageId); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || + snapshot.header.publicId != strategyPublicId || + snapshot.activePage?.page.publicId != pageId) { + return null; + } + + final page = _remotePageById(snapshot: snapshot, pageId: pageId); + if (page == null) { + return null; + } + + final remoteElements = { + for (final element in (snapshot.elementsByPage[page.publicId] ?? + const [])) + if (!element.deleted) + EntitySyncKey.element(page.publicId, element.publicId): + ProjectedPageElement( + publicId: element.publicId, + elementType: element.elementType, + payload: element.payload, + sortIndex: element.sortIndex, + ), + }; + final remoteLineups = { + for (final lineup + in (snapshot.lineupsByPage[page.publicId] ?? const [])) + if (!lineup.deleted) + EntitySyncKey.lineup(page.publicId, lineup.publicId): + ProjectedPageLineup( + publicId: lineup.publicId, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + ), + }; + + var projectedSettingsPayload = snapshot.activePage?.content.settings; + var projectedIsAttack = page.isAttack; + + final pageOverlays = state.overlayByEntityKey.entries.where( + (entry) => + entry.key.pageId == page.publicId && + !excludedOverlays.contains(entry.key), + ); + for (final entry in pageOverlays) { + final overlay = entry.value; + switch (overlay.entityType) { + case ActivePageOverlayEntityType.pageDescriptor: + if (overlay.desiredPayload == null) { + continue; + } + final decoded = _decodeObject(overlay.desiredPayload!); + final isAttack = decoded['isAttack']; + if (isAttack is bool) { + projectedIsAttack = isAttack; + } + continue; + case ActivePageOverlayEntityType.pageContent: + if (overlay.desiredPayload != null) { + final decoded = _decodeObject(overlay.desiredPayload!); + projectedSettingsPayload = + cloudObjectPayloadOrNull(decoded['settings']); + } + continue; + case ActivePageOverlayEntityType.element: + if (overlay.deletion) { + remoteElements.remove(entry.key); + continue; + } + final elementId = entry.key.entityId; + if (elementId == null || overlay.desiredPayload == null) { + continue; + } + final decoded = cloudPayloadData(overlay.desiredPayload); + final elementType = decoded['elementType'] as String? ?? + (overlay.desiredPayload as Map?)?['kind'] as String?; + if (elementType == null) { + _debugLog( + 'projected.skip_overlay ${entry.key} reason=missing_element_type', + ); + continue; + } + remoteElements[entry.key] = ProjectedPageElement( + publicId: elementId, + elementType: elementType, + payload: Map.from(overlay.desiredPayload! as Map), + sortIndex: overlay.desiredSortIndex ?? 0, + ); + continue; + case ActivePageOverlayEntityType.lineup: + if (overlay.deletion) { + remoteLineups.remove(entry.key); + continue; + } + final lineupId = entry.key.entityId; + if (lineupId == null || overlay.desiredPayload == null) { + continue; + } + remoteLineups[entry.key] = ProjectedPageLineup( + publicId: lineupId, + payload: Map.from(overlay.desiredPayload! as Map), + sortIndex: overlay.desiredSortIndex ?? 0, + ); + continue; + } + } + + _debugLog( + 'projected.rehydrate page=${page.publicId} overlays=${pageOverlays.length}', + ); + + return ActivePageProjectedState( + pageId: page.publicId, + pageName: page.name, + isAttack: projectedIsAttack, + settingsPayload: projectedSettingsPayload, + elements: remoteElements.values.toList(growable: false) + ..sortBySortIndex((item) => item.sortIndex), + lineups: remoteLineups.values.toList(growable: false) + ..sortBySortIndex((item) => item.sortIndex), + ); + } + + Map _decodeObject(Object payload) { + final decoded = payload is String ? jsonDecode(payload) : payload; + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + return const {}; + } + + Map _normalizedRemoteEntities( + RemoteEditorSnapshot snapshot, + String pageId, + ) { + final page = _remotePageById(snapshot: snapshot, pageId: pageId); + if (page == null) { + return const {}; + } + + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != page.publicId) { + return const {}; + } + + final entities = { + EntitySyncKey.pageDescriptor(page.publicId): _NormalizedEntity( + key: EntitySyncKey.pageDescriptor(page.publicId), + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + payload: {'isAttack': page.isAttack}, + sortIndex: null, + revision: page.revision, + deleted: false, + ), + EntitySyncKey.pageContent(page.publicId): _NormalizedEntity( + key: EntitySyncKey.pageContent(page.publicId), + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: { + 'settings': pageSnapshot.content.settings, + }, + sortIndex: null, + revision: pageSnapshot.content.revision, + deleted: false, + ), + }; + + for (final element in (snapshot.elementsByPage[page.publicId] ?? + const [])) { + final key = EntitySyncKey.element(page.publicId, element.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: element.payload, + sortIndex: element.sortIndex, + revision: element.revision, + deleted: element.deleted, + ); + } + + for (final lineup + in (snapshot.lineupsByPage[page.publicId] ?? const [])) { + final key = EntitySyncKey.lineup(page.publicId, lineup.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: lineup.payload, + sortIndex: lineup.sortIndex, + revision: lineup.revision, + deleted: lineup.deleted, + ); + } + + return entities; + } + + Map _normalizedLocalEntities( + String pageId) { + final entities = {}; + + final descriptorKey = EntitySyncKey.pageDescriptor(pageId); + entities[descriptorKey] = _NormalizedEntity( + key: descriptorKey, + overlayEntityType: ActivePageOverlayEntityType.pageDescriptor, + payload: { + 'isAttack': ref.read(mapProvider).isAttack, + }, + sortIndex: null, + revision: 0, + deleted: false, + ); + final contentKey = EntitySyncKey.pageContent(pageId); + entities[contentKey] = _NormalizedEntity( + key: contentKey, + overlayEntityType: ActivePageOverlayEntityType.pageContent, + payload: { + 'settings': ref.read(strategySettingsProvider).toJson(), + }, + sortIndex: null, + revision: 0, + deleted: false, + ); + + // A row keeps the sortIndex it was first sent with and new rows go after + // the page's highest. Removing an element or lineup (here or by a + // teammate) then rewrites no row after it, which would collide with a + // teammate editing one of them; the server never renumbers either. + int? knownSortIndex(EntitySyncKey key) => + state.overlayByEntityKey[key]?.desiredSortIndex ?? + _hydratedBaseByEntityKey[key]?.sortIndex; + + /// Hands out sortIndexes after the page's highest for [kind]. + int Function() freshSortIndexes(EntitySyncKeyKind kind) { + bool onPage(EntitySyncKey key) => + key.pageId == pageId && key.kind == kind; + var next = 1 + + [ + for (final key in _hydratedBaseByEntityKey.keys) + if (onPage(key)) knownSortIndex(key) ?? 0, + for (final key in state.overlayByEntityKey.keys) + if (onPage(key)) knownSortIndex(key) ?? 0, + ].fold(-1, max); + return () => next++; + } + + // Elements stack in list order within each kind (each kind is its own + // canvas layer), and moving or restoring one brings it to the front of its + // list. A known sortIndex is kept while it still sorts after the one + // before it in the same list (a tie only while the two are still in the + // order hydration drew them); an element that moved ahead goes after the + // page's highest. + final freshElementSortIndex = freshSortIndexes(EntitySyncKeyKind.element); + final previousByKind = <_CollabElementKind, (EntitySyncKey, int)>{}; + bool drawnInThisOrder(EntitySyncKey first, EntitySyncKey second) { + final a = _hydratedPositionByKey[first]; + final b = _hydratedPositionByKey[second]; + return a != null && b != null && a < b; + } + + int stackedSortIndex(EntitySyncKey key, _CollabElementKind kind) { + final known = knownSortIndex(key); + if (_heldDeletedKeys.contains(key) && known != null) return known; + final previous = previousByKind[kind]; + final keep = known != null && + (previous == null || + known > previous.$2 || + (known == previous.$2 && drawnInThisOrder(previous.$1, key))); + final sortIndex = keep ? known : freshElementSortIndex(); + previousByKind[kind] = (key, sortIndex); + return sortIndex; + } + + for (final envelope in _collectLocalElementEnvelopes()) { + final key = EntitySyncKey.element(pageId, envelope.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.element, + payload: cloudElementPayload( + kind: envelope.kind.name, data: envelope.payload), + sortIndex: stackedSortIndex(key, envelope.kind), + revision: 0, + deleted: false, + ); + } + + // One row per lineup group, holding its lineups and their spots as the + // canvas draws them. A group nobody changed matches its stored row, so + // it is never written. A new group never takes the id of a row this page + // has or had, so it never lands on a teammate's group or a deleted one. + final freshLineupSortIndex = freshSortIndexes(EntitySyncKeyKind.lineup); + final lineupGroupOf = _lineupGroupOfByPage[pageId] ??= {}; + final lineupRows = cloudLineupRows( + ref.read(lineUpProvider).graph, + groupOf: lineupGroupOf, + takenGroupIds: { + for (final key in [ + ..._hydratedBaseByEntityKey.keys, + ...state.overlayByEntityKey.keys, + ]) + if (key.pageId == pageId && key.kind == EntitySyncKeyKind.lineup) + key.entityId!, + }, + ); + _rememberLineupGroups(lineupGroupOf, lineupRows.groupOf); + for (final row in lineupRows.rows) { + final key = EntitySyncKey.lineup(pageId, row.publicId); + entities[key] = _NormalizedEntity( + key: key, + overlayEntityType: ActivePageOverlayEntityType.lineup, + payload: row.payload, + sortIndex: knownSortIndex(key) ?? freshLineupSortIndex(), + revision: 0, + deleted: false, + ); + } + return entities; + } + + List<_CollabElementEnvelope> _collectLocalElementEnvelopes() { + final envelopes = <_CollabElementEnvelope>[]; + + for (final agent in ref.read(agentProvider)) { + final payload = Map.from(agent.toJson()) + ..putIfAbsent('elementType', () => _CollabElementKind.agent.name); + envelopes.add( + _CollabElementEnvelope( + publicId: agent.id, + kind: _CollabElementKind.agent, + payload: payload, + ), + ); + } + + for (final ability in ref.read(abilityProvider)) { + final payload = Map.from(ability.toJson()) + ..putIfAbsent('elementType', () => _CollabElementKind.ability.name); + envelopes.add( + _CollabElementEnvelope( + publicId: ability.id, + kind: _CollabElementKind.ability, + payload: payload, + ), + ); + } + + for (final drawing in ref.read(drawingProvider).elements) { + final encoded = + jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + final payload = Map.from( + (encoded.isEmpty ? {} : encoded.first) as Map, + )..putIfAbsent('elementType', () => _CollabElementKind.drawing.name); + envelopes.add( + _CollabElementEnvelope( + publicId: drawing.id, + kind: _CollabElementKind.drawing, + payload: payload, + ), + ); + } + + for (final text + in ref.read(textProvider.notifier).snapshotForPersistence()) { + final payload = Map.from(text.toJson()) + ..putIfAbsent('elementType', () => _CollabElementKind.text.name); + envelopes.add( + _CollabElementEnvelope( + publicId: text.id, + kind: _CollabElementKind.text, + payload: payload, + ), + ); + } + + for (final image in ref.read(placedImageProvider).images) { + final payload = Map.from( + cloudImagePayloadFromPlacedImage(image), + )..putIfAbsent('elementType', () => _CollabElementKind.image.name); + envelopes.add( + _CollabElementEnvelope( + publicId: image.id, + kind: _CollabElementKind.image, + payload: payload, + ), + ); + } + + for (final utility in ref.read(utilityProvider)) { + final payload = Map.from(utility.toJson()) + ..putIfAbsent('elementType', () => _CollabElementKind.utility.name); + envelopes.add( + _CollabElementEnvelope( + publicId: utility.id, + kind: _CollabElementKind.utility, + payload: payload, + ), + ); + } + + return envelopes; + } + + ActivePageOverlayEntry _overlayFromDesiredEntity({ + required EntitySyncKey key, + required _NormalizedEntity desired, + required _NormalizedEntity? hydratedBase, + required ActivePageOverlayEntry? existingOverlay, + }) { + return ActivePageOverlayEntry( + entityKey: key, + entityType: desired.overlayEntityType, + desiredPayload: desired.payload, + desiredSortIndex: desired.sortIndex, + deletion: desired.deleted, + baseRevision: existingOverlay?.baseRevision ?? hydratedBase?.revision, + baseDeleted: + existingOverlay?.baseDeleted ?? hydratedBase?.deleted ?? false, + dirtyAt: DateTime.now(), + ); + } + + StrategyOp? _strategyOpFromOverlay({ + required String pageId, + required ActivePageOverlayEntry overlay, + }) { + final entityId = overlay.entityKey.entityId; + switch (overlay.entityType) { + case ActivePageOverlayEntityType.pageDescriptor: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; + return PagePatchOp( + opId: const Uuid().v4(), + pagePublicId: pageId, + payload: Map.from(overlay.desiredPayload as Map), + expectedPageRevision: baseRevision, + ); + case ActivePageOverlayEntityType.pageContent: + final baseRevision = overlay.baseRevision; + if (baseRevision == null) return null; + final payload = Map.from( + overlay.desiredPayload as Map, + ); + return PageContentPatchOp( + opId: const Uuid().v4(), + pagePublicId: pageId, + settings: Map.from(payload['settings'] as Map), + expectedPageContentRevision: baseRevision, + ); + case ActivePageOverlayEntityType.element: + if (entityId == null) { + return null; + } + if (overlay.deletion) { + // A delete after a local add has no revision until that add lands. + // Zero cannot land early; the outbox rebases the successor from the + // accepted add acknowledgment. + final baseRevision = overlay.baseRevision ?? 0; + return ElementDeleteOp( + opId: const Uuid().v4(), + elementPublicId: entityId, + pagePublicId: pageId, + expectedElementRevision: baseRevision, + ); + } + final payload = + Map.from(overlay.desiredPayload as Map); + return overlay.baseRevision == null || overlay.baseDeleted + ? ElementAddOp( + opId: const Uuid().v4(), + elementPublicId: entityId, + pagePublicId: pageId, + payload: payload, + sortIndex: overlay.desiredSortIndex ?? 0, + expectedElementRevision: overlay.baseRevision, + ) + : ElementPatchOp( + opId: const Uuid().v4(), + elementPublicId: entityId, + pagePublicId: pageId, + payload: payload, + sortIndex: overlay.desiredSortIndex, + expectedElementRevision: overlay.baseRevision!, + ); + case ActivePageOverlayEntityType.lineup: + if (entityId == null) { + return null; + } + if (overlay.deletion) { + final baseRevision = overlay.baseRevision ?? 0; + return LineupDeleteOp( + opId: const Uuid().v4(), + lineupPublicId: entityId, + pagePublicId: pageId, + expectedLineupRevision: baseRevision, + ); + } + final payload = + Map.from(overlay.desiredPayload as Map); + return overlay.baseRevision == null || overlay.baseDeleted + ? LineupAddOp( + opId: const Uuid().v4(), + lineupPublicId: entityId, + pagePublicId: pageId, + payload: payload, + sortIndex: overlay.desiredSortIndex ?? 0, + expectedLineupRevision: overlay.baseRevision, + ) + : LineupPatchOp( + opId: const Uuid().v4(), + lineupPublicId: entityId, + pagePublicId: pageId, + payload: payload, + sortIndex: overlay.desiredSortIndex, + expectedLineupRevision: overlay.baseRevision!, + ); + } + } + + bool _overlayMatchesRemote( + ActivePageOverlayEntry overlay, + _NormalizedEntity? remote, + ) { + if (overlay.deletion) { + return remote == null || remote.deleted; + } + if (remote == null || remote.deleted) { + return false; + } + return _payloadsEquivalent(overlay.desiredPayload, remote.payload) && + overlay.desiredSortIndex == remote.sortIndex; + } + + bool _needsSuccessor({ + required String pageId, + required EntitySyncKey key, + required ActivePageOverlayEntry overlay, + required StrategyOpQueueState queueState, + }) { + if (queueState.successorByEntityKey.containsKey(key)) { + return false; + } + + final predecessor = queueState.inFlightByEntityKey[key]?.pending.op ?? + queueState.queuedByEntityKey[key]?.pending.op; + if (predecessor == null) return false; + final desired = _strategyOpFromOverlay(pageId: pageId, overlay: overlay); + return desired != null && !_opsEquivalent(predecessor, desired); + } + + bool _opsEquivalent(StrategyOp left, StrategyOp right) { + return left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + cloudJsonEquivalent(left.payload, right.payload) && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision; + } + + bool _entitiesEquivalent( + _NormalizedEntity? local, + _NormalizedEntity? remote, + ) { + if (identical(local, remote)) { + return true; + } + if (local == null) { + return remote?.deleted ?? false; + } + if (remote == null) { + return false; + } + return local.deleted == remote.deleted && + _payloadsEquivalent(local.payload, remote.payload) && + local.sortIndex == remote.sortIndex && + local.overlayEntityType == remote.overlayEntityType; + } + + bool _payloadsEquivalent(Object? left, Object? right) { + return cloudJsonEquivalent( + _withFieldDefaults(left), + _withFieldDefaults(right), + ); + } + + /// Payloads written before a field existed compare equal to the field's + /// default, so hydrating old cloud data never authors a rewrite of it. + Object? _withFieldDefaults(Object? value) { + if (value is List) { + return [for (final item in value) _withFieldDefaults(item)]; + } + if (value is! Map) { + return value; + } + final normalized = { + for (final entry in value.entries) + entry.key.toString(): _withFieldDefaults(entry.value), + }; + final visualState = normalized['visualState']; + if (visualState is Map) { + visualState.putIfAbsent('showVisionCone', () => true); + } + // Only agents carry an AgentState; firearms default to none. + if (normalized.containsKey('state')) { + normalized.putIfAbsent('weapon', () => WeaponType.none.name); + } + return normalized; + } + + void _debugLog(String message) { + assert(() { + log(message, name: 'active_page_live_sync'); + return true; + }()); + } + + RemotePage? _remotePageById({ + required RemoteEditorSnapshot snapshot, + required String pageId, + }) { + for (final page in snapshot.pages) { + if (page.publicId == pageId) { + return page; + } + } + return null; + } +} + +class _NormalizedEntity { + const _NormalizedEntity({ + required this.key, + required this.overlayEntityType, + required this.payload, + required this.sortIndex, + required this.revision, + required this.deleted, + }); + + final EntitySyncKey key; + final ActivePageOverlayEntityType overlayEntityType; + final Object payload; + final int? sortIndex; + final int revision; + final bool deleted; +} + +class _CollabElementEnvelope { + const _CollabElementEnvelope({ + required this.publicId, + required this.kind, + required this.payload, + }); + + final String publicId; + final _CollabElementKind kind; + final Map payload; +} + +enum _CollabElementKind { + agent, + ability, + drawing, + text, + image, + utility; +} diff --git a/lib/providers/collab/client_upgrade_required_provider.dart b/lib/providers/collab/client_upgrade_required_provider.dart new file mode 100644 index 00000000..a1472a63 --- /dev/null +++ b/lib/providers/collab/client_upgrade_required_provider.dart @@ -0,0 +1,46 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; + +/// Whether the server refuses this build's cloud protocol. Set by the first +/// refusal anything notes. Only [recheck] clears it, when the server accepts +/// the protocol again (a rolled-back deploy); otherwise a reload (web) or an +/// update (desktop) is the way out, and either starts the app again. +final clientUpgradeRequiredProvider = + NotifierProvider( + ClientUpgradeRequiredNotifier.new, +); + +class ClientUpgradeRequiredNotifier extends Notifier { + bool _disposed = false; + + @override + bool build() { + ref.onDispose(() => _disposed = true); + return false; + } + + /// Notes [error] when it is the server refusing this build, and returns + /// whether it was. + bool noteError(Object? error) { + if (!isClientUpgradeRequiredError(error)) return false; + state = true; + return true; + } + + /// Asks the server once, cheaply, whether it accepts this protocol again, + /// and clears the refusal if it does. Anything short of a clear answer + /// (offline, an older server) leaves it as it was. + Future recheck() async { + if (!state) return; + final bool accepted; + try { + accepted = await ref + .read(convexStrategyRepositoryProvider) + .serverAcceptsCloudProtocol(); + } catch (_) { + return; + } + if (accepted && !_disposed) state = false; + } +} diff --git a/lib/providers/collab/cloud_collab_provider.dart b/lib/providers/collab/cloud_collab_provider.dart new file mode 100644 index 00000000..16de25f2 --- /dev/null +++ b/lib/providers/collab/cloud_collab_provider.dart @@ -0,0 +1,70 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; + +class CloudCollabModeState { + const CloudCollabModeState({ + required this.featureFlagEnabled, + required this.forceLocalFallback, + }); + + final bool featureFlagEnabled; + final bool forceLocalFallback; + + bool isCloudEnabled({ + required bool isAuthenticated, + required bool isConvexUserReady, + }) { + return featureFlagEnabled && + isAuthenticated && + isConvexUserReady && + !forceLocalFallback; + } + + CloudCollabModeState copyWith({ + bool? featureFlagEnabled, + bool? forceLocalFallback, + }) { + return CloudCollabModeState( + featureFlagEnabled: featureFlagEnabled ?? this.featureFlagEnabled, + forceLocalFallback: forceLocalFallback ?? this.forceLocalFallback, + ); + } +} + +final cloudCollabModeProvider = + NotifierProvider( + CloudCollabModeNotifier.new, +); + +final isCloudCollabEnabledProvider = Provider((ref) { + AppAuthState auth; + try { + auth = ref.watch(authProvider); + } catch (_) { + return false; + } + final mode = ref.watch(cloudCollabModeProvider); + return mode.isCloudEnabled( + isAuthenticated: auth.isAuthenticated, + isConvexUserReady: auth.isConvexUserReady, + ); +}); + +class CloudCollabModeNotifier extends Notifier { + @override + CloudCollabModeState build() { + // Feature-flagged dual mode; default enabled for authenticated users. + return const CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); + } + + void setFeatureFlagEnabled(bool enabled) { + state = state.copyWith(featureFlagEnabled: enabled); + } + + void setForceLocalFallback(bool enabled) { + state = state.copyWith(forceLocalFallback: enabled); + } +} diff --git a/lib/providers/collab/cloud_media_cache_provider.dart b/lib/providers/collab/cloud_media_cache_provider.dart new file mode 100644 index 00000000..81a0ab54 --- /dev/null +++ b/lib/providers/collab/cloud_media_cache_provider.dart @@ -0,0 +1,250 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/services/local_image_file.dart'; + +class CloudMediaCacheState { + const CloudMediaCacheState({ + this.strategyPublicId, + this.cachedAssetIds = const {}, + this.inFlightAssetIds = const {}, + this.lastErrorByAssetId = const {}, + }); + + final String? strategyPublicId; + final Set cachedAssetIds; + final Set inFlightAssetIds; + final Map lastErrorByAssetId; + + CloudMediaCacheState copyWith({ + String? strategyPublicId, + bool clearStrategyPublicId = false, + Set? cachedAssetIds, + Set? inFlightAssetIds, + Map? lastErrorByAssetId, + }) { + return CloudMediaCacheState( + strategyPublicId: + clearStrategyPublicId ? null : (strategyPublicId ?? this.strategyPublicId), + cachedAssetIds: cachedAssetIds ?? this.cachedAssetIds, + inFlightAssetIds: inFlightAssetIds ?? this.inFlightAssetIds, + lastErrorByAssetId: lastErrorByAssetId ?? this.lastErrorByAssetId, + ); + } +} + +class CloudImageDownloadException implements Exception { + const CloudImageDownloadException(this.statusCode); + final int statusCode; + + @override + String toString() => 'Failed to download image ($statusCode).'; +} + +/// How long one image download may take before it counts as failed. +const cloudImageDownloadTimeout = Duration(seconds: 60); + +/// Downloads a cloud image's bytes from [url], through [client] when given +/// (the caller owns it and closes it). A signed URL the host refuses (401, +/// 403, or 404: it expired) is swapped once for [freshUrl] from the server. +/// Throws [CloudImageDownloadException] when the bytes don't come, and a +/// [TimeoutException] when a request stalls. +Future downloadCloudImageBytes( + String url, { + required Future Function() freshUrl, + http.Client? client, +}) async { + Future get(String url) => + (client?.get(Uri.parse(url)) ?? http.get(Uri.parse(url))) + .timeout(cloudImageDownloadTimeout); + var response = await get(url); + if (const {401, 403, 404}.contains(response.statusCode)) { + final refreshed = await freshUrl(); + if (refreshed != null && refreshed.isNotEmpty) { + response = await get(refreshed); + } + } + if (response.statusCode < 200 || response.statusCode >= 300) { + throw CloudImageDownloadException(response.statusCode); + } + return response.bodyBytes; +} + +final cloudMediaCacheProvider = + NotifierProvider( + CloudMediaCacheNotifier.new, +); + +class CloudMediaCacheNotifier extends Notifier { + @override + CloudMediaCacheState build() { + return const CloudMediaCacheState(); + } + + Future localAssetPath({ + required String strategyId, + required String assetId, + required String fileExtension, + }) async { + final imageFolder = await PlacedImageProvider.getImageFolder(strategyId); + return PlacedImageProvider.buildImageFilePath( + imageFolder.path, + assetId, + fileExtension, + ); + } + + Future localFileForAsset({ + required String strategyId, + required RemoteImageAsset asset, + }) async { + final file = File( + await localAssetPath( + strategyId: strategyId, + assetId: asset.publicId, + fileExtension: asset.fileExtension, + ), + ); + if (await file.exists()) { + _markCached(asset.publicId); + return file; + } + return null; + } + + Future ensureAssetsCached({ + required String strategyId, + required String strategyPublicId, + required Iterable assets, + }) async { + final uniqueAssets = { + for (final asset in assets) asset.publicId: asset, + }.values.toList(growable: false); + for (final asset in uniqueAssets) { + await ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyPublicId, + asset: asset, + ); + } + } + + /// Downloads [asset] into the strategy's image folder. A platform without + /// image files (web) has nothing to cache; it paints from the URL. + Future ensureAssetCached({ + required String strategyId, + required String strategyPublicId, + required RemoteImageAsset asset, + }) async { + if (!deviceHasImageFiles) return null; + final existing = await localFileForAsset(strategyId: strategyId, asset: asset); + if (existing != null) { + return existing; + } + + if (asset.url == null || asset.url!.isEmpty) { + _recordError(asset.publicId, 'Missing remote asset URL.'); + return null; + } + + if (state.inFlightAssetIds.contains(asset.publicId)) { + return null; + } + + _markInFlight(asset.publicId, strategyPublicId); + try { + final bytes = await downloadCloudImageBytes( + asset.url!, + freshUrl: () { + final linkView = ref.read(shareLinkViewProvider); + return ref.read(convexStrategyRepositoryProvider).getImageAssetUrl( + strategyPublicId: strategyPublicId, + assetPublicId: asset.publicId, + // A signed-out reader's only access is the link they opened. + shareToken: linkView?.strategyPublicId == strategyPublicId + ? linkView!.token + : null, + ); + }, + ); + final output = File( + await localAssetPath( + strategyId: strategyId, + assetId: asset.publicId, + fileExtension: asset.fileExtension, + ), + ); + await output.parent.create(recursive: true); + await output.writeAsBytes(bytes, flush: true); + _markCached(asset.publicId); + return output; + } catch (error) { + _recordError(asset.publicId, '$error'); + return null; + } finally { + _clearInFlight(asset.publicId); + } + } + + Future ensureAssetIdsCached({ + required String strategyId, + required String strategyPublicId, + required Map assetsById, + required Iterable assetIds, + }) async { + for (final assetId in assetIds.toSet()) { + final asset = assetsById[assetId]; + if (asset == null) { + return false; + } + final file = await ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyPublicId, + asset: asset, + ); + if (file == null || !await file.exists()) { + return false; + } + } + return true; + } + + void resetStrategy(String? strategyPublicId) { + state = CloudMediaCacheState(strategyPublicId: strategyPublicId); + } + + void _markCached(String assetId) { + final cached = {...state.cachedAssetIds, assetId}; + final errors = Map.from(state.lastErrorByAssetId) + ..remove(assetId); + state = state.copyWith( + cachedAssetIds: cached, + lastErrorByAssetId: errors, + ); + } + + void _markInFlight(String assetId, String strategyPublicId) { + state = state.copyWith( + strategyPublicId: strategyPublicId, + inFlightAssetIds: {...state.inFlightAssetIds, assetId}, + ); + } + + void _clearInFlight(String assetId) { + final next = {...state.inFlightAssetIds}..remove(assetId); + state = state.copyWith(inFlightAssetIds: next); + } + + void _recordError(String assetId, String error) { + final errors = Map.from(state.lastErrorByAssetId) + ..[assetId] = error; + state = state.copyWith(lastErrorByAssetId: errors); + } +} diff --git a/lib/providers/collab/cloud_media_upload_queue_provider.dart b/lib/providers/collab/cloud_media_upload_queue_provider.dart new file mode 100644 index 00000000..0a4f6e0a --- /dev/null +++ b/lib/providers/collab/cloud_media_upload_queue_provider.dart @@ -0,0 +1,1724 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +class CloudMediaUploadQueueState { + const CloudMediaUploadQueueState({ + required this.jobs, + required this.isProcessing, + this.loadIssues = const [], + this.durableLoaded = true, + this.durabilityError, + }); + + final List jobs; + final bool isProcessing; + final List loadIssues; + final bool durableLoaded; + final String? durabilityError; + + bool get outboxIsReliable => + durableLoaded && loadIssues.isEmpty && durabilityError == null; + + List jobsForStrategy(String? strategyPublicId) { + if (strategyPublicId == null) { + return const []; + } + return jobs + .where((job) => job.strategyPublicId == strategyPublicId) + .toList(growable: false); + } + + int pendingCountForStrategy(String? strategyPublicId) { + return jobsForStrategy(strategyPublicId).length; + } + + int errorCountForStrategy(String? strategyPublicId) { + return jobsForStrategy(strategyPublicId) + .where((job) => job.state == CloudMediaJobState.failed) + .length; + } + + CloudMediaUploadQueueState copyWith({ + List? jobs, + bool? isProcessing, + String? durabilityError, + bool clearDurabilityError = false, + }) { + return CloudMediaUploadQueueState( + jobs: jobs ?? this.jobs, + isProcessing: isProcessing ?? this.isProcessing, + loadIssues: loadIssues, + durableLoaded: durableLoaded, + durabilityError: clearDurabilityError + ? null + : (durabilityError ?? this.durabilityError), + ); + } +} + +class _UploadProgressToastState { + const _UploadProgressToastState({ + required this.message, + required this.progress, + required this.isComplete, + }); + + final String message; + final double progress; + final bool isComplete; +} + +final cloudMediaUploadQueueProvider = + NotifierProvider( + CloudMediaUploadQueueNotifier.new, +); + +/// Which of the images asked about a strategy's content shows on the +/// server, pages in its trash included (their images are wanted while the +/// page can be restored), or null while the server cannot tell. Asked before +/// a pending upload is dropped as no longer wanted. +typedef CloudMediaReferenceLoader = Future?> Function( + String strategyPublicId, + Iterable assetPublicIds, +); + +final cloudMediaReferenceLoaderProvider = Provider( + (ref) => ref.watch(convexStrategyRepositoryProvider).fetchReferencedAssetIds, +); + +final cloudMediaAccountIdProvider = Provider( + (ref) => ref.watch(authProvider.select((state) => state.user?.id)), +); + +enum _MediaOutboxMutation { write, remove } + +class CloudMediaUploadQueueNotifier + extends Notifier { + static const Duration _blockedRetryDelay = Duration(seconds: 30); + Timer? _retryTimer; + Timer? _uploadCompletionDismissTimer; + ToastificationItem? _uploadProgressToast; + ValueNotifier<_UploadProgressToastState>? _uploadProgressToastState; + int _uploadProgressTotalJobs = 0; + final Map _uploadBytesSentByJob = {}; + final Map _uploadBytesTotalByJob = {}; + final Set _uploadCompletingJobs = {}; + final Set _uploadCompletedJobs = {}; + final Map _jobsByStorageKey = {}; + final Set _restoredStagedJobIds = {}; + final Map _unverifiedByStorageKey = {}; + + /// Jobs that wait, not uploading, until a check finds whether anything + /// still references their image; if nothing does, they go with their + /// bytes. See [recheckAfterDiscardedWork] and [_settleReferenceChecks]. + /// Held in memory only, so every job restored at launch with a durable + /// reference starts marked: the work that referenced it may have been + /// discarded before a restart. Restored staged jobs have their own check. + final Set _referenceCheckDue = {}; + Future? _settling; + Future _writeTail = Future.value(); + bool _disposed = false; + late DurableCloudMediaOutboxStore _store; + + ConvexStrategyRepository get _repo => + ref.read(convexStrategyRepositoryProvider); + + @override + CloudMediaUploadQueueState build() { + _store = ref.read(durableCloudMediaOutboxStoreProvider); + final loaded = _store.load(); + _jobsByStorageKey.addEntries( + loaded.jobs.map( + (job) => MapEntry(durableCloudMediaOutboxStorageKey(job), job), + ), + ); + _restoredStagedJobIds.addAll( + loaded.jobs + .where((job) => !job.referenceDurable) + .map(durableCloudMediaOutboxStorageKey), + ); + _referenceCheckDue.addAll( + loaded.jobs + .where((job) => job.referenceDurable) + .map(durableCloudMediaOutboxStorageKey), + ); + // Unreadable records might name bytes, so only a clean load can prove + // that stored bytes belong to no job of any account. + if (loaded.issues.isEmpty) { + final jobKeys = { + for (final job in loaded.jobs) pendingMediaStorageKey(_bytesKey(job)), + }; + unawaited(_pruneAbandonedMediaBytes(jobKeys)); + } + ref.onDispose(() { + _disposed = true; + _retryTimer?.cancel(); + _uploadCompletionDismissTimer?.cancel(); + final toast = _uploadProgressToast; + if (toast != null) { + Settings.dismissToast(toast, showRemoveAnimation: false); + } + _uploadProgressToastState?.dispose(); + }); + + ref.listen(authProvider, (previous, next) { + final becameReady = + !(previous?.isConvexUserReady ?? false) && next.isConvexUserReady; + final authRecovered = (previous?.hasActiveAuthIncident ?? false) && + !next.hasActiveAuthIncident; + if (becameReady || authRecovered) { + retryNow(ignoreBackoff: true); + } + }); + + ref.listen(cloudMediaAccountIdProvider, (previous, next) { + if (previous == next) return; + _refreshState(); + if (next != null && next.isNotEmpty) { + retryNow(ignoreBackoff: true); + } + }); + + ref.listen>(convexConnectionProvider, (previous, next) { + final reconnected = + previous?.valueOrNull != true && next.valueOrNull == true; + if (reconnected) { + retryNow(ignoreBackoff: true); + } + }); + + if (_jobsByStorageKey.isNotEmpty) { + scheduleMicrotask(() { + if (!_disposed) retryNow(ignoreBackoff: true); + }); + } + + return CloudMediaUploadQueueState( + jobs: _readJobs(), + isProcessing: false, + loadIssues: loaded.issues, + durableLoaded: true, + ); + } + + Future enqueuePlacedImageUpload({ + required String imagePublicId, + String? strategyPublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + final strategyState = ref.read(strategyProvider); + final resolvedStrategyId = strategyPublicId ?? strategyState.strategyId; + if (strategyState.source != StrategySource.cloud || + resolvedStrategyId == null) { + _logMedia( + 'enqueue.skipped image=$imagePublicId ' + 'strategy=$resolvedStrategyId ' + 'source=${strategyState.source?.name ?? 'unknown'}', + ); + return; + } + + final accountId = _requireActiveAccountId(); + final normalizedExtension = normalizeImageExtension(fileExtension ?? ''); + await _upsertJob( + CloudMediaUploadJob( + jobId: imagePublicId, + accountId: accountId, + strategyPublicId: resolvedStrategyId, + assetPublicId: imagePublicId, + fileExtension: normalizedExtension, + mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), + width: width, + height: height, + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.now(), + ), + ); + _logMedia( + 'enqueue.placed_image_staged ${_describeJob(_getJob(imagePublicId))}', + ); + } + + /// Queues an image whose bytes are already on this device: its file, or + /// its pending bytes where images are not files. + Future enqueueJobForLocalBytes({ + required String strategyPublicId, + required String assetPublicId, + required String fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + final accountId = _requireActiveAccountId(); + final normalizedExtension = normalizeImageExtension(fileExtension); + await _upsertJob( + CloudMediaUploadJob( + jobId: assetPublicId, + accountId: accountId, + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + fileExtension: normalizedExtension, + mimeType: mimeType ?? mimeTypeForImageExtension(normalizedExtension), + width: width, + height: height, + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.now(), + ), + ); + _logMedia('enqueue.local_bytes ${_describeJob(_getJob(assetPublicId))}'); + retryNow(ignoreBackoff: true); + } + + Future enqueueLineupMediaJobs({ + required String strategyPublicId, + required Iterable images, + }) async { + final accountId = _requireActiveAccountId(); + final imageList = images.toList(growable: false); + final jobs = [ + for (final image in imageList) + CloudMediaUploadJob( + jobId: image.id, + accountId: accountId, + strategyPublicId: strategyPublicId, + assetPublicId: image.id, + fileExtension: normalizeImageExtension(image.fileExtension), + mimeType: mimeTypeForImageExtension(image.fileExtension), + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.now(), + ), + ]; + await _upsertJobsAtomically(jobs); + for (final image in imageList) { + _logMedia('enqueue.lineup_image ${_describeJob(_getJob(image.id))}'); + } + } + + Future commitStagedMediaReferences({ + required String strategyPublicId, + required Iterable assetPublicIds, + }) async { + final accountId = _requireActiveAccountId(); + final requestedIds = assetPublicIds.toSet(); + final staged = _readJobs() + .where( + (job) => + job.accountId == accountId && + job.strategyPublicId == strategyPublicId && + requestedIds.contains(job.assetPublicId) && + !job.referenceDurable, + ) + .toList(growable: false); + if (staged.isEmpty) return; + + await ref + .read(strategyProvider.notifier) + .notifyCloudMutation(flushImmediately: false); + final opQueueState = ref.read(strategyOpQueueProvider); + if (!opQueueState.outboxIsReliable) { + throw StateError( + 'The strategy reference could not be saved to the durable outbox.', + ); + } + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + if (durableOps.issues.isNotEmpty) { + throw StateError( + 'The strategy reference outbox contains unreadable saved work.', + ); + } + final pendingOps = [ + for (final record in durableOps.records) + if (record.accountId == accountId && + record.strategyPublicId == strategyPublicId) ...[ + record.pending.op, + if (record.successorPending case final successor?) successor.op, + ], + ]; + final missingReferences = staged + .where( + (job) => !pendingOps.any( + (op) => _opReferencesAsset(op, job.assetPublicId), + ), + ) + .toList(growable: false); + if (missingReferences.isNotEmpty) { + Set? serverReferences; + if (ref.read(authProvider).isConvexUserReady && + ref.read(convexConnectionSnapshotProvider)) { + try { + serverReferences = await ref.read(cloudMediaReferenceLoaderProvider)( + strategyPublicId, + [for (final job in missingReferences) job.assetPublicId], + ); + } catch (_) { + serverReferences = null; + } + } + if (serverReferences == null || + missingReferences.any( + (job) => !serverReferences!.contains(job.assetPublicId), + )) { + _scheduleRetryForNextEligibleJob( + minimumDelay: _blockedRetryDelay, + ); + throw StateError( + 'The strategy reference was not admitted to the durable outbox.', + ); + } + } + + if (ref.read(cloudMediaAccountIdProvider) != accountId) { + throw StateError('The active account changed before media was queued.'); + } + + await _putJobsAtomically([ + for (final job in staged) + job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ), + ]); + _refreshState(); + await retryNow(ignoreBackoff: true); + } + + Future retryNow({bool ignoreBackoff = false}) async { + if (_disposed) return; + _retryTimer?.cancel(); + await _settleReferenceChecks(); + if (_disposed) return; + await _reconcileStagedJobReferences(); + if (_disposed) return; + _logMedia( + 'retry_now ignoreBackoff=$ignoreBackoff jobs=${_readJobs().length}', + ); + unawaited(_processNextJob(ignoreBackoff: ignoreBackoff)); + } + + Future setActiveStrategy(String? strategyPublicId) async { + _refreshState(); + if (strategyPublicId != null) { + await retryNow(ignoreBackoff: true); + } + } + + Future reconcilePageMedia({ + required String strategyPublicId, + required Iterable placedImages, + required Map assetsById, + }) async { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return; + PendingMediaKey keyFor(String assetPublicId) => ( + accountId: accountId, + strategyPublicId: strategyPublicId, + assetPublicId: assetPublicId, + ); + + for (final image in placedImages) { + final asset = assetsById[image.id]; + final hasActiveRemote = + asset?.uploadStatus == 'active' && (asset?.url?.isNotEmpty ?? false); + if (hasActiveRemote || _getJob(image.id) != null) { + continue; + } + + final bytes = await _findMediaBytes( + keyFor(image.id), + fileExtension: image.fileExtension ?? '', + ); + if (bytes == null) { + _logMedia( + 'reconcile.local_missing image=${image.id} ' + 'strategy=$strategyPublicId status=${asset?.uploadStatus ?? 'none'}', + ); + continue; + } + + await enqueueJobForLocalBytes( + strategyPublicId: strategyPublicId, + assetPublicId: image.id, + fileExtension: image.fileExtension ?? '', + ); + } + + // The page now paints these from the cloud. Bytes no job needs go now; + // bytes whose upload is still attaching go when it lands. + final pending = ref.read(pendingMediaBytesProvider.notifier); + for (final asset in assetsById.values) { + final servedFromCloud = + asset.uploadStatus == 'active' && (asset.url?.isNotEmpty ?? false); + final key = keyFor(asset.publicId); + if (!servedFromCloud || pending.bytesFor(key) == null) continue; + if (_getJob(asset.publicId) == null) { + await _releaseMediaBytes(key); + } else { + pending.markServedFromCloud(key); + } + } + } + + static PendingMediaKey _bytesKey(CloudMediaUploadJob job) => ( + accountId: job.accountId, + strategyPublicId: job.strategyPublicId, + assetPublicId: job.assetPublicId, + ); + + /// Where [key]'s bytes are on this device, or null when they are gone. + /// Pending bytes (web) come first; otherwise the image file. + Future _findMediaBytes( + PendingMediaKey key, { + required String fileExtension, + }) async { + final pending = ref.read(pendingMediaBytesProvider.notifier).bytesFor(key); + if (pending != null) return MemoryMediaBytes(pending); + if (!ref.read(imageFilesOnDeviceProvider)) return null; + final file = await PlacedImageProvider.getImageFile( + strategyID: key.strategyPublicId, + imageID: key.assetPublicId, + fileExtension: fileExtension, + ); + return await file.exists() ? FileMediaBytes(file.path) : null; + } + + /// Drops the bytes held for [key], or when [attached] only their saved + /// copy, so the editor keeps painting them until the cloud URL arrives. + /// Image files are the strategy's own copy and stay. + Future _releaseMediaBytes( + PendingMediaKey key, { + bool attached = false, + }) async { + final pending = ref.read(pendingMediaBytesProvider.notifier); + try { + if (attached) { + await pending.markAttached(key); + } else { + await pending.remove(key); + } + } catch (error, stackTrace) { + // Costs browser storage only: a later launch prunes bytes no job + // needs. + AppErrorReporter.reportError( + 'Failed to release pending media bytes.', + error: error, + stackTrace: stackTrace, + source: 'cloud_media.upload_queue', + ); + } + } + + Future _pruneAbandonedMediaBytes(Set jobKeys) async { + try { + await ref.read(pendingMediaBytesProvider.notifier).pruneAbandoned( + hasJob: (key) => jobKeys.contains(pendingMediaStorageKey(key)), + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to prune pending media bytes.', + error: error, + stackTrace: stackTrace, + source: 'cloud_media.upload_queue', + ); + } + } + + /// Unsaved work of [strategyPublicId] was discarded (a page the server + /// deleted), so an image only it placed has nothing left to show it. Every + /// upload of the strategy is checked before its next attempt, and goes if + /// no queued change and no page on the server references its image. + Future recheckAfterDiscardedWork(String strategyPublicId) { + _referenceCheckDue.addAll([ + for (final job in _readJobs()) + if (job.strategyPublicId == strategyPublicId) + durableCloudMediaOutboxStorageKey(job), + ]); + return retryNow(ignoreBackoff: true); + } + + /// Settles each pending reference check: a job whose image nothing + /// references any more goes, with its bytes. One still referenced may + /// upload. When it cannot be told, a job that could upload may (nothing + /// the user made is lost that way), and a staged one keeps its check, + /// which is its only way out. Needs the server, and waits while an upload + /// runs (it may be one of these jobs, marked since it started). A removal + /// that fails is reported and tried again later; the job keeps waiting + /// and other jobs go on. One pass at a time, and every retry waits for + /// it: two passes could decide the same job differently. A marked job + /// never uploads, so no upload runs on a job a pass may delete. + Future _settleReferenceChecks() => _settling ??= + _settleReferenceChecksOnce().whenComplete(() => _settling = null); + + Future _settleReferenceChecksOnce() async { + if (_referenceCheckDue.isEmpty || + state.isProcessing || + !ref.read(authProvider).isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + return; + } + for (final key in _referenceCheckDue.toList(growable: false)) { + final job = _jobsByStorageKey[key]; + if (job == null) { + _referenceCheckDue.remove(key); + continue; + } + // Another account's job waits for that account. + if (!_belongsToActiveAccount(job)) continue; + final bool? deleted; + try { + deleted = await _deleteJobWhenReferenceIsGone(job); + } catch (_) { + continue; + } + if (deleted == false || (deleted == null && job.referenceDurable)) { + _referenceCheckDue.remove(key); + } + } + } + + Future clearJobsForStrategy(String strategyPublicId) async { + final jobs = _readJobs() + .where((job) => job.strategyPublicId == strategyPublicId) + .toList(growable: false); + for (final job in jobs) { + await _deleteJob(job); + } + _refreshState(); + } + + Future _processNextJob({bool ignoreBackoff = false}) async { + if (state.isProcessing) { + return; + } + + final nextJob = _nextRunnableJob(ignoreBackoff: ignoreBackoff); + if (nextJob == null) { + _logMedia( + 'process.idle ignoreBackoff=$ignoreBackoff jobs=${_readJobs().length}', + ); + _scheduleRetryForNextEligibleJob(); + return; + } + + state = state.copyWith(isProcessing: true); + _logMedia('process.start ${_describeJob(nextJob)}'); + late final bool madeProgress; + try { + madeProgress = await _processJob(nextJob); + } finally { + _refreshState(isProcessing: false); + _logMedia('process.finish jobs=${state.jobs.length}'); + } + + if (madeProgress && _readJobs().isNotEmpty) { + // Only bypass backoff for the initial user-triggered kick. Follow-up + // attempts must honor retry timing so transient attach failures do not + // hammer Convex in a tight loop. + unawaited(_processNextJob(ignoreBackoff: false)); + } + } + + /// Whether [job] may upload: its image is referenced by saved work, and + /// no check of that reference is pending. + bool _canUpload(CloudMediaUploadJob job) => + job.referenceDurable && + !_referenceCheckDue.contains(durableCloudMediaOutboxStorageKey(job)); + + CloudMediaUploadJob? _nextRunnableJob({required bool ignoreBackoff}) { + // A copy: _readJobs() may be the shared `const []` (signed out). + final jobs = [..._readJobs()] + ..sort((a, b) => a.updatedAt.compareTo(b.updatedAt)); + final now = DateTime.now(); + for (final job in jobs) { + if (!_canUpload(job)) { + continue; + } + if (ignoreBackoff || !_nextAttemptAt(job).isAfter(now)) { + return job; + } + } + return null; + } + + Future _processJob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) { + _logMedia('process.blocked account_mismatch ${_describeJob(job)}'); + return false; + } + final mode = ref.read(cloudCollabModeProvider); + final auth = ref.read(authProvider); + if (!mode.featureFlagEnabled || mode.forceLocalFallback) { + _logMedia( + 'process.blocked featureFlag=${mode.featureFlagEnabled} ' + 'forceLocalFallback=${mode.forceLocalFallback} ${_describeJob(job)}', + ); + _scheduleRetryForNextEligibleJob(minimumDelay: _blockedRetryDelay); + return false; + } + if (!auth.isAuthenticated || + !auth.isConvexUserReady || + auth.hasActiveAuthIncident || + !ref.read(convexConnectionSnapshotProvider)) { + _logMedia( + 'process.blocked auth=${auth.isAuthenticated} ' + 'userReady=${auth.isConvexUserReady} ' + 'authIncident=${auth.hasActiveAuthIncident} ' + 'connected=${ref.read(convexConnectionSnapshotProvider)} ' + '${_describeJob(job)}', + ); + _scheduleRetryForNextEligibleJob(minimumDelay: _blockedRetryDelay); + return false; + } + + if (!job.hasUploadedRemoteObject) { + await _uploadJobBlob(job); + return true; + } + + await _attachUploadedJob(job); + return true; + } + + Future _uploadJobBlob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) return; + try { + _logMedia('upload.local_lookup ${_describeJob(job)}'); + final bytes = await _findMediaBytes( + _bytesKey(job), + fileExtension: job.fileExtension, + ); + if (bytes == null) { + _logMedia('upload.local_missing ${_describeJob(job)}'); + if (await _deleteJobWhenReferenceIsGone(job) == true) { + return; + } + await _markJobFailed( + job, + 'Local media file is missing.', + showToast: job.attempts == 0, + ); + return; + } + + final byteSize = await bytes.length(); + if (!_belongsToActiveAccount(job)) { + _logMedia('upload.deferred account_changed ${_describeJob(job)}'); + return; + } + _setUploadByteProgress( + job.jobId, + sentBytes: 0, + totalBytes: byteSize, + ); + _logMedia( + 'upload.intent.request bytes=$byteSize ${_describeJob(job)}', + ); + final intent = await _repo.generateImageUploadUrl( + strategyPublicId: job.strategyPublicId, + assetPublicId: job.assetPublicId, + mimeType: job.mimeType, + fileExtension: job.fileExtension, + byteSize: byteSize, + width: job.width, + height: job.height, + ); + if (intent.uploadUrl.isEmpty) { + throw StateError('Empty R2 upload URL'); + } + _logMedia( + 'upload.intent.received provider=${intent.provider} ' + 'uploadId=${intent.uploadId} objectKey=${intent.objectKey} ' + 'expiresAt=${intent.expiresAt.toIso8601String()} ' + 'maxBytes=${intent.maxBytes} ${_describeJob(job)}', + ); + if (intent.maxBytes > 0 && byteSize > intent.maxBytes) { + throw StateError( + 'Image exceeds maximum upload size (${intent.maxBytes} bytes).', + ); + } + + if (!_belongsToActiveAccount(job)) { + _logMedia('upload.deferred account_changed ${_describeJob(job)}'); + return; + } + + _logMedia('upload.put.start bytes=$byteSize ${_describeJob(job)}'); + final response = await _putBytesWithProgress( + job: job, + bytes: bytes, + uploadUrl: intent.uploadUrl, + headers: intent.requiredHeaders, + byteSize: byteSize, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError( + 'Upload failed (${response.statusCode}): ${response.body}', + ); + } + _logMedia( + 'upload.put.success status=${response.statusCode} ' + 'etag=${response.headers['etag']} ${_describeJob(job)}', + ); + + await _putJob( + job.copyWith( + provider: intent.provider, + uploadId: intent.uploadId, + objectKey: intent.objectKey, + storageId: null, + etag: response.headers['etag'], + byteSize: byteSize, + uploadUrlExpiresAt: intent.expiresAt, + state: CloudMediaJobState.pendingAttach, + attempts: 0, + lastError: null, + updatedAt: DateTime.now(), + ), + ); + _refreshState(); + _markUploadCompleting(job.jobId); + _logMedia( + 'upload.pending_attach ${_describeJob(_getJob(job.jobId))}', + ); + } catch (error) { + if (await _dropIfStrategyDeleted(job, error)) return; + _reportMediaFailure('upload', error, job); + await _markJobFailed( + job, + '$error', + showToast: job.attempts == 0, + ); + } + } + + /// Deletes [job] if nothing references its image any more. Returns true + /// when it went, false when something still references the image, and + /// null when that could not be told (offline, unreadable outbox, a failed + /// read, the job changed meanwhile). + Future _deleteJobWhenReferenceIsGone( + CloudMediaUploadJob job, + ) async { + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + if (durableOps.issues.isNotEmpty) return null; + final pendingReferences = durableOps.records.any( + (record) => + record.accountId == job.accountId && + record.strategyPublicId == job.strategyPublicId && + (_opReferencesAsset(record.pending.op, job.assetPublicId) || + (record.successorPending != null && + _opReferencesAsset( + record.successorPending!.op, + job.assetPublicId, + ))), + ); + if (pendingReferences) return false; + if (!ref.read(authProvider).isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + return null; + } + + final Set? serverReferences; + try { + serverReferences = await ref.read(cloudMediaReferenceLoaderProvider)( + job.strategyPublicId, + [job.assetPublicId], + ); + } catch (error) { + _logMedia( + 'missing_source.reference_check_deferred ' + 'strategy=${job.strategyPublicId} error=$error', + ); + return null; + } + final current = _getJob(job.jobId); + if (serverReferences == null || + !_belongsToActiveAccount(job) || + current == null || + current.updatedAt != job.updatedAt) { + return null; + } + if (serverReferences.contains(job.assetPublicId)) return false; + + final deleted = await _deleteJob(job, onlyIfUnreferenced: true); + if (!deleted) return null; + _refreshState(); + _logMedia( + 'missing_source.removed_unreferenced job=${job.jobId} ' + 'strategy=${job.strategyPublicId}', + ); + return true; + } + + Future _putBytesWithProgress({ + required CloudMediaUploadJob job, + required MediaBytesSource bytes, + required String uploadUrl, + required Map headers, + required int byteSize, + }) async { + final request = http.StreamedRequest('PUT', Uri.parse(uploadUrl)); + request.headers.addAll(headers); + if (!request.headers.containsKey('Content-Type')) { + request.headers['Content-Type'] = job.mimeType; + } + request.contentLength = byteSize; + + final responseFuture = request.send(); + var sentBytes = 0; + try { + await for (final chunk in bytes.openRead()) { + request.sink.add(chunk); + sentBytes += chunk.length; + _setUploadByteProgress( + job.jobId, + sentBytes: sentBytes, + totalBytes: byteSize, + ); + } + if (sentBytes < byteSize) { + _setUploadByteProgress( + job.jobId, + sentBytes: byteSize, + totalBytes: byteSize, + ); + } + unawaited(request.sink.close().catchError((_) {})); + final streamedResponse = await responseFuture; + return http.Response.fromStream(streamedResponse); + } catch (_) { + unawaited(request.sink.close().catchError((_) {})); + rethrow; + } + } + + Future _attachUploadedJob(CloudMediaUploadJob job) async { + if (!_belongsToActiveAccount(job)) return; + try { + _logMedia('attach.start ${_describeJob(job)}'); + if ((job.provider == 'r2' || job.uploadId != null) && + (job.uploadId == null || job.objectKey == null)) { + throw StateError('R2 upload job is missing upload intent metadata.'); + } + if ((job.provider == null || job.provider == 'convex') && + job.uploadId == null && + job.storageId == null) { + throw StateError('Upload job is missing remote storage metadata.'); + } + + await _repo.completeImageUpload( + strategyPublicId: job.strategyPublicId, + assetPublicId: job.assetPublicId, + provider: job.provider, + uploadId: job.uploadId, + objectKey: job.objectKey, + storageId: job.storageId, + etag: job.etag, + mimeType: job.mimeType, + fileExtension: job.fileExtension, + byteSize: job.byteSize, + width: job.width, + height: job.height, + ); + await _deleteJob(job, attached: true); + if (_uploadProgressToast != null) { + _markUploadComplete(job.jobId); + } + _refreshState(); + _logMedia('attach.success image=${job.assetPublicId} ' + 'strategy=${job.strategyPublicId}'); + } catch (error) { + if (await _dropIfStrategyDeleted(job, error)) return; + _reportMediaFailure('attach', error, job); + final missingIntent = isMissingImageUploadIntentError(error); + await _markJobFailed( + missingIntent + ? job.copyWith( + provider: null, + uploadId: null, + objectKey: null, + storageId: null, + etag: null, + uploadUrlExpiresAt: null, + ) + : job, + '$error', + showToast: job.attempts == 0, + ); + } + } + + /// Drops every job of [job]'s strategy when [error] is NOT_FOUND and the + /// server confirms the strategy is gone; retrying could never succeed. + /// Nothing is lost that the user cannot recreate: the image came from + /// their own file, and the change that placed it waits in the strategy + /// outbox, which the library surfaces for them to discard. Other NOT_FOUND + /// errors (an uploaded image the store lost) keep the ordinary retry. + Future _dropIfStrategyDeleted( + CloudMediaUploadJob job, + Object error, + ) async { + if (!isTypedConvexNotFoundError(error)) return false; + final bool deleted; + try { + deleted = await _repo.strategyIsDeleted(job.strategyPublicId); + } catch (_) { + return false; + } + if (!deleted) return false; + final count = _readJobs() + .where((pending) => pending.strategyPublicId == job.strategyPublicId) + .length; + AppErrorReporter.reportWarning( + 'Strategy ${job.strategyPublicId} was deleted on the server; dropped ' + '$count pending image upload(s) for it.', + source: 'cloud_media.upload_queue', + ); + await clearJobsForStrategy(job.strategyPublicId); + return true; + } + + Future _markJobFailed( + CloudMediaUploadJob job, + String errorMessage, { + required bool showToast, + }) async { + await _putJob( + job.copyWith( + state: CloudMediaJobState.failed, + attempts: job.attempts + 1, + lastError: errorMessage, + updatedAt: DateTime.now(), + ), + ); + _refreshState(); + _logMedia( + 'job.failed showToast=$showToast ${_describeJob(_getJob(job.jobId))}'); + // The sync button explains a refusal of this build; Save cannot fix it. + final upgradeRequired = ref + .read(clientUpgradeRequiredProvider.notifier) + .noteError(errorMessage); + if (showToast && !upgradeRequired) { + Settings.showToast( + message: 'Media upload failed. Tap Save to retry cloud sync.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + _scheduleRetryForNextEligibleJob(); + } + + DateTime _nextAttemptAt(CloudMediaUploadJob job) { + if (job.attempts <= 0) { + return job.updatedAt; + } + + final baseSeconds = 5 * (1 << (job.attempts - 1).clamp(0, 5)); + final cappedSeconds = baseSeconds > 300 ? 300 : baseSeconds; + return job.updatedAt.add(Duration(seconds: cappedSeconds)); + } + + void _scheduleRetryForNextEligibleJob({Duration? minimumDelay}) { + _retryTimer?.cancel(); + final allJobs = _readJobs(); + final jobs = allJobs.where(_canUpload).toList(growable: false); + if (jobs.isEmpty) { + if (allJobs.any((job) => !_canUpload(job))) { + _retryTimer = Timer( + minimumDelay ?? _blockedRetryDelay, + () => unawaited(retryNow()), + ); + } + return; + } + + final now = DateTime.now(); + DateTime? earliest; + for (final job in jobs) { + final candidate = _nextAttemptAt(job); + if (earliest == null || candidate.isBefore(earliest)) { + earliest = candidate; + } + } + + if (earliest == null) { + return; + } + + var delay = + earliest.isAfter(now) ? earliest.difference(now) : Duration.zero; + if (minimumDelay != null && delay < minimumDelay) { + delay = minimumDelay; + } + _logMedia( + 'retry.scheduled delayMs=${delay.inMilliseconds} jobs=${jobs.length}'); + _retryTimer = Timer(delay, () => unawaited(retryNow())); + } + + Future _reconcileStagedJobReferences() async { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return; + final staged = _readJobs() + .where( + (job) => job.accountId == accountId && !job.referenceDurable, + ) + .toList(growable: false); + if (staged.isEmpty) return; + + final durableOps = ref.read(durableStrategyOutboxStoreProvider).load(); + final pendingOps = + <({String accountId, String strategyPublicId, StrategyOp op})>[ + for (final record in durableOps.records) ...[ + ( + accountId: record.accountId, + strategyPublicId: record.strategyPublicId, + op: record.pending.op, + ), + if (record.successorPending case final successor?) + ( + accountId: record.accountId, + strategyPublicId: record.strategyPublicId, + op: successor.op, + ), + ], + ]; + final readyFromDurableOps = staged + .where( + (job) => pendingOps.any( + (pending) => + pending.accountId == accountId && + pending.strategyPublicId == job.strategyPublicId && + _opReferencesAsset(pending.op, job.assetPublicId), + ), + ) + .toList(growable: false); + for (final job in readyFromDurableOps) { + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + final promoted = job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ); + await _writeJobs([promoted], () => _store.put(promoted), replacing: job); + } + + if (durableOps.issues.isNotEmpty) return; + final unresolved = _readJobs() + .where((job) => !job.referenceDurable) + .toList(growable: false); + if (unresolved.isEmpty || + !ref.read(authProvider).isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + return; + } + + final byStrategy = >{}; + for (final job in unresolved) { + (byStrategy[job.strategyPublicId] ??= []).add(job); + } + for (final entry in byStrategy.entries) { + final Set? serverReferences; + try { + serverReferences = await ref.read(cloudMediaReferenceLoaderProvider)( + entry.key, + [for (final job in entry.value) job.assetPublicId], + ); + } catch (error) { + _logMedia( + 'reference_reconcile.deferred strategy=${entry.key} error=$error', + ); + continue; + } + if (serverReferences == null) continue; + + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + + for (final job in entry.value) { + if (ref.read(cloudMediaAccountIdProvider) != accountId) return; + // Both jobs and op references may change while the server is read. + // Never promote or delete using a stale copy of a media job. + if (!identical(_getJob(job.jobId), job)) continue; + final key = durableCloudMediaOutboxStorageKey(job); + final localReference = _hasLocalReference(job); + if (serverReferences.contains(job.assetPublicId) || + localReference == true) { + final promoted = job.copyWith( + referenceDurable: true, + updatedAt: DateTime.now(), + ); + await _writeJobs( + [promoted], + () => _store.put(promoted), + replacing: job, + ); + } else if (localReference == false && + _restoredStagedJobIds.contains(key)) { + await _deleteJob(job, onlyIfUnreferenced: true); + } + } + _refreshState(); + } + } + + bool _opReferencesAsset(StrategyOp op, String assetPublicId) { + if (op is ElementAddOp) { + return op.elementPublicId == assetPublicId; + } + if (op is ElementPatchOp) { + return op.elementPublicId == assetPublicId; + } + if (op is LineupAddOp) { + return _jsonContainsAssetId(op.payload, assetPublicId); + } + if (op is LineupPatchOp) { + return _jsonContainsAssetId(op.payload, assetPublicId); + } + return false; + } + + bool _jsonContainsAssetId(Object? value, String assetPublicId) { + if (value is Map) { + if (value['id'] == assetPublicId) return true; + return value.values.any( + (child) => _jsonContainsAssetId(child, assetPublicId), + ); + } + if (value is Iterable) { + return value.any( + (child) => _jsonContainsAssetId(child, assetPublicId), + ); + } + return false; + } + + Future _upsertJob(CloudMediaUploadJob nextJob) async { + await _putJob(_mergeJob(nextJob)); + _refreshState(); + } + + Future _upsertJobsAtomically( + Iterable nextJobs, + ) async { + final mergedById = {}; + for (final nextJob in nextJobs) { + mergedById[nextJob.jobId] = _mergeJob(nextJob); + } + await _putJobsAtomically(mergedById.values); + _refreshState(); + } + + CloudMediaUploadJob _mergeJob(CloudMediaUploadJob nextJob) { + final existing = + _jobsByStorageKey[durableCloudMediaOutboxStorageKey(nextJob)]; + if (existing == null) return nextJob; + if (existing.isFailed) { + return existing.copyWith( + strategyPublicId: nextJob.strategyPublicId, + assetPublicId: nextJob.assetPublicId, + fileExtension: nextJob.fileExtension, + mimeType: nextJob.mimeType, + width: nextJob.width, + height: nextJob.height, + byteSize: nextJob.byteSize, + state: nextJob.state, + referenceDurable: nextJob.referenceDurable, + attempts: 0, + provider: null, + uploadId: null, + objectKey: null, + storageId: null, + etag: null, + uploadUrlExpiresAt: null, + lastError: null, + updatedAt: DateTime.now(), + ); + } + return existing.copyWith( + strategyPublicId: nextJob.strategyPublicId, + assetPublicId: nextJob.assetPublicId, + fileExtension: nextJob.fileExtension, + mimeType: nextJob.mimeType, + width: nextJob.width, + height: nextJob.height, + byteSize: nextJob.byteSize, + referenceDurable: existing.referenceDurable || nextJob.referenceDurable, + updatedAt: DateTime.now(), + ); + } + + List _readJobs() { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return const []; + return _jobsByStorageKey.values + .where((job) => job.accountId == accountId) + .toList(growable: false); + } + + CloudMediaUploadJob? _getJob(String jobId) { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) return null; + final job = _jobsByStorageKey[durableCloudMediaOutboxStorageKeyFor( + accountId: accountId, + jobId: jobId, + )]; + return job != null && _belongsToActiveAccount(job) ? job : null; + } + + String _requireActiveAccountId() { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) { + throw StateError( + 'Cloud media cannot be queued without an authenticated account.', + ); + } + return accountId; + } + + bool _belongsToActiveAccount(CloudMediaUploadJob job) { + final accountId = ref.read(cloudMediaAccountIdProvider); + return accountId != null && + accountId.isNotEmpty && + job.accountId == accountId; + } + + Future _putJob(CloudMediaUploadJob job) async { + await _writeJobs([job], () => _store.put(job)); + } + + Future _putJobsAtomically( + Iterable jobs, + ) async { + final jobList = jobs.toList(growable: false); + if (jobList.isEmpty) return; + await _writeJobs(jobList, () => _store.putAll(jobList)); + } + + // Null means unreadable records or unverified writes prevent proving that + // this asset is unreferenced. Include in-memory ops still being persisted. + bool? _hasLocalReference(CloudMediaUploadJob job) { + final durable = ref.read(durableStrategyOutboxStoreProvider).load(); + final queue = ref.read(strategyOpQueueProvider); + if (durable.issues.isNotEmpty || + !queue.durableLoaded || + queue.hasDurabilityFailure) return null; + final durableReference = durable.records.any((record) => + record.accountId == job.accountId && + record.strategyPublicId == job.strategyPublicId && + (_opReferencesAsset(record.pending.op, job.assetPublicId) || + (record.successorPending != null && + _opReferencesAsset( + record.successorPending!.op, job.assetPublicId)))); + if (durableReference) return true; + final pendingReference = queue.accountId == job.accountId && + queue.strategyPublicId == job.strategyPublicId && + queue.pending.any( + (pending) => _opReferencesAsset(pending.op, job.assetPublicId)); + return pendingReference ? null : false; + } + + Future _writeJobs( + List jobs, + Future Function() persist, { + CloudMediaUploadJob? replacing, + }) => + _serializeWrite(() async { + if (replacing != null && + !identical(_getJob(replacing.jobId), replacing)) return; + final keys = jobs.map(durableCloudMediaOutboxStorageKey).toSet(); + try { + await persist(); + for (final job in jobs) { + final key = durableCloudMediaOutboxStorageKey(job); + _jobsByStorageKey[key] = job; + if (job.referenceDurable) _restoredStagedJobIds.remove(key); + } + for (final key in keys) { + _unverifiedByStorageKey.remove(key); + } + _refreshState(); + } catch (error, stackTrace) { + for (final key in keys) { + _unverifiedByStorageKey[key] = _MediaOutboxMutation.write; + } + _recordDurabilityFailure(error, stackTrace); + rethrow; + } + }); + + /// Removes [job] from the outbox, then the bytes it was holding, and only + /// its own: bytes are scoped like the job. [attached] keeps those bytes + /// painting until the cloud URL arrives. + Future _deleteJob( + CloudMediaUploadJob job, { + bool onlyIfUnreferenced = false, + bool attached = false, + }) async { + final deleted = await _removeJobRecord( + job, + onlyIfUnreferenced: onlyIfUnreferenced, + ); + if (deleted) { + await _releaseMediaBytes(_bytesKey(job), attached: attached); + } + return deleted; + } + + Future _removeJobRecord( + CloudMediaUploadJob job, { + required bool onlyIfUnreferenced, + }) => + _serializeWrite(() async { + final key = durableCloudMediaOutboxStorageKey(job); + if (onlyIfUnreferenced && + (!_belongsToActiveAccount(job) || + !identical(_jobsByStorageKey[key], job) || + _unverifiedByStorageKey[key] == _MediaOutboxMutation.write || + _hasLocalReference(job) != false)) return false; + try { + await _store.remove(job); + _jobsByStorageKey.remove(key); + _restoredStagedJobIds.remove(key); + _referenceCheckDue.remove(key); + _unverifiedByStorageKey.remove(key); + _refreshState(); + return true; + } catch (error, stackTrace) { + _unverifiedByStorageKey[key] = _MediaOutboxMutation.remove; + _recordDurabilityFailure(error, stackTrace); + rethrow; + } + }); + + Future _serializeWrite(Future Function() action) { + final result = _writeTail.then((_) => action()); + _writeTail = + result.then((_) {}, onError: (Object _, StackTrace __) {}); + return result; + } + + void _recordDurabilityFailure(Object error, StackTrace stackTrace) { + AppErrorReporter.reportError( + 'Failed to update the durable media outbox.', + error: error, + stackTrace: stackTrace, + source: 'cloud_media.upload_queue', + ); + _refreshState(isProcessing: false); + } + + void _refreshState({bool? isProcessing}) { + if (_disposed) return; + final accountId = ref.read(cloudMediaAccountIdProvider); + final prefix = + accountId == null ? null : '${Uri.encodeComponent(accountId)}|'; + final hasUnverifiedWrites = prefix != null && + _unverifiedByStorageKey.keys.any((key) => key.startsWith(prefix)); + state = state.copyWith( + jobs: _readJobs(), + isProcessing: isProcessing ?? state.isProcessing, + durabilityError: hasUnverifiedWrites + ? 'Media work could not be saved on this device.' + : null, + clearDurabilityError: !hasUnverifiedWrites, + ); + _syncUploadProgressToast(); + } + + void _syncUploadProgressToast() { + final activeUploadCount = state.jobs + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) + .length; + + if (activeUploadCount > 0) { + if (_uploadProgressToast == null) { + final uploadHasStarted = _uploadBytesTotalByJob.isNotEmpty || + _uploadCompletingJobs.isNotEmpty; + if (!uploadHasStarted) { + return; + } + _uploadCompletionDismissTimer?.cancel(); + _uploadProgressTotalJobs = activeUploadCount; + _uploadProgressToastState = ValueNotifier<_UploadProgressToastState>( + _buildUploadToastState(activeUploadCount), + ); + _uploadProgressToast = _showUploadProgressToast(); + return; + } + + final inferredTotal = _uploadCompletedJobs.length + activeUploadCount; + if (inferredTotal > _uploadProgressTotalJobs) { + _uploadProgressTotalJobs = inferredTotal; + } + _uploadProgressToastState?.value = + _buildUploadToastState(activeUploadCount); + return; + } + + final toast = _uploadProgressToast; + if (toast == null) { + _resetUploadToastState(); + return; + } + + final toastState = _uploadProgressToastState; + if (toastState != null && _uploadCompletedJobs.isNotEmpty) { + toastState.value = const _UploadProgressToastState( + message: 'Image upload complete', + progress: 1, + isComplete: true, + ); + _uploadCompletionDismissTimer?.cancel(); + _uploadCompletionDismissTimer = Timer( + const Duration(milliseconds: 1400), + () { + if (_uploadProgressToast == toast) { + Settings.dismissToast(toast); + _resetUploadToastState(); + } + }, + ); + return; + } + + Settings.dismissToast(toast); + _resetUploadToastState(); + } + + _UploadProgressToastState _buildUploadToastState(int activeUploadCount) { + final totalJobs = _uploadProgressTotalJobs <= 0 + ? activeUploadCount + : _uploadProgressTotalJobs; + final progress = totalJobs <= 0 ? 0.0 : _currentUploadProgress(totalJobs); + return _UploadProgressToastState( + message: activeUploadCount == 1 + ? 'Uploading image...' + : 'Uploading $activeUploadCount images...', + progress: progress, + isComplete: false, + ); + } + + double _currentUploadProgress(int totalJobs) { + var progressUnits = _uploadCompletedJobs.length.toDouble(); + final activeJobIds = state.jobs + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) + .map((job) => job.jobId); + + for (final jobId in activeJobIds) { + if (_uploadCompletedJobs.contains(jobId)) { + continue; + } + if (_uploadCompletingJobs.contains(jobId)) { + progressUnits += 0.94; + continue; + } + + final totalBytes = _uploadBytesTotalByJob[jobId] ?? 0; + final sentBytes = _uploadBytesSentByJob[jobId] ?? 0; + if (totalBytes <= 0) { + continue; + } + final byteProgress = (sentBytes / totalBytes).clamp(0.0, 1.0).toDouble(); + progressUnits += byteProgress * 0.9; + } + + return (progressUnits / totalJobs).clamp(0.0, 1.0).toDouble(); + } + + ToastificationItem _showUploadProgressToast() { + final stateNotifier = _uploadProgressToastState!; + return toastification.showCustom( + autoCloseDuration: null, + alignment: Alignment.bottomCenter, + builder: (context, holder) { + return ValueListenableBuilder<_UploadProgressToastState>( + valueListenable: stateNotifier, + builder: (context, toastState, _) { + return AnimatedContainer( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + width: 280, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: toastState.isComplete + ? Settings.allyBGColor + : Settings.tacticalVioletTheme.primary, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: Text( + toastState.message, + key: ValueKey(toastState.message), + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Colors.white), + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: TweenAnimationBuilder( + tween: Tween( + begin: 0, + end: toastState.progress.clamp(0, 1), + ), + duration: const Duration(milliseconds: 350), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + return LinearProgressIndicator( + value: value, + minHeight: 4, + backgroundColor: Colors.white.withValues(alpha: 0.22), + valueColor: const AlwaysStoppedAnimation( + Colors.white, + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + void _resetUploadToastState() { + _uploadCompletionDismissTimer?.cancel(); + _uploadCompletionDismissTimer = null; + _uploadProgressToast = null; + _uploadProgressToastState?.dispose(); + _uploadProgressToastState = null; + _uploadProgressTotalJobs = 0; + _uploadBytesSentByJob.clear(); + _uploadBytesTotalByJob.clear(); + _uploadCompletingJobs.clear(); + _uploadCompletedJobs.clear(); + } + + void _setUploadByteProgress( + String jobId, { + required int sentBytes, + required int totalBytes, + }) { + _uploadBytesSentByJob[jobId] = sentBytes; + _uploadBytesTotalByJob[jobId] = totalBytes; + _publishUploadProgressToast(); + } + + void _markUploadCompleting(String jobId) { + _uploadCompletingJobs.add(jobId); + _publishUploadProgressToast(); + } + + void _markUploadComplete(String jobId) { + _uploadCompletingJobs.remove(jobId); + _uploadCompletedJobs.add(jobId); + _uploadBytesSentByJob.remove(jobId); + _uploadBytesTotalByJob.remove(jobId); + _publishUploadProgressToast(); + } + + void _publishUploadProgressToast() { + final activeUploadCount = state.jobs + .where( + (job) => + job.state != CloudMediaJobState.failed && job.referenceDurable, + ) + .length; + if (activeUploadCount <= 0) { + return; + } + if (_uploadProgressToastState == null) { + _syncUploadProgressToast(); + } + if (_uploadProgressToastState == null) return; + _uploadProgressToastState!.value = + _buildUploadToastState(activeUploadCount); + } + + void _logMedia(String message) { + AppErrorReporter.reportInfo( + redactSyncDiagnosticText(message), + source: 'cloud_media.upload_queue', + ); + } + + void _reportMediaFailure( + String stage, + Object error, + CloudMediaUploadJob job, + ) { + AppErrorReporter.reportWarning( + redactSyncDiagnosticText('$stage.failed ${_describeJob(job)}'), + source: 'cloud_media.upload_queue', + error: redactSyncDiagnosticText(error), + ); + } + + String _describeJob(CloudMediaUploadJob? job) { + if (job == null) { + return 'job=null'; + } + return 'job=${job.jobId} image=${job.assetPublicId} ' + 'account=${job.accountId} ' + 'strategy=${job.strategyPublicId} state=${job.state.name} ' + 'attempts=${job.attempts} provider=${job.provider ?? 'none'} ' + 'hasUploadId=${job.uploadId != null} ' + 'hasObjectKey=${job.objectKey != null} ' + 'hasStorageId=${job.storageId != null} ' + 'byteSize=${job.byteSize ?? 'unknown'} ' + 'lastError=${job.lastError ?? 'none'}'; + } +} diff --git a/lib/providers/collab/cloud_migration_provider.dart b/lib/providers/collab/cloud_migration_provider.dart new file mode 100644 index 00000000..7b0642f5 --- /dev/null +++ b/lib/providers/collab/cloud_migration_provider.dart @@ -0,0 +1,341 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_cloud_migration.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:uuid/uuid.dart'; + +final cloudMigrationProvider = + NotifierProvider(CloudMigrationNotifier.new); + +final cloudMigrationApiProvider = Provider((ref) { + return _DefaultCloudMigrationApi( + ref.read(convexStrategyRepositoryProvider), + ); +}); + +abstract class CloudMigrationApi { + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }); + + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + bool? initialPageIsAutoNamed, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }); + + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + bool? isAutoNamed, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }); + + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }); +} + +class _DefaultCloudMigrationApi implements CloudMigrationApi { + const _DefaultCloudMigrationApi(this._repository); + + final ConvexStrategyRepository _repository; + + @override + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }) { + return _repository.createFolder( + publicId: publicId, + name: name, + parentFolderPublicId: parentFolderPublicId, + iconId: iconId, + ); + } + + @override + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + bool? initialPageIsAutoNamed, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }) { + return _repository.createStrategyWithInitialPage( + publicId: publicId, + name: name, + mapData: mapData, + initialPagePublicId: initialPagePublicId, + initialPageName: initialPageName, + initialPageIsAutoNamed: initialPageIsAutoNamed, + initialPageIsAttack: initialPageIsAttack, + initialPageSettings: initialPageSettings, + folderPublicId: folderPublicId, + themeProfileId: themeProfileId, + themeOverridePalette: themeOverridePalette, + ); + } + + @override + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + bool? isAutoNamed, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }) async { + await _repository.addPage( + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + name: name, + isAutoNamed: isAutoNamed, + sortIndex: sortIndex, + isAttack: isAttack, + settings: settings, + expectedRevision: expectedRevision, + ); + } + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) { + return _repository.applyBatch( + strategyPublicId: strategyPublicId, + clientId: clientId, + ops: ops, + ); + } +} + +class CloudMigrationNotifier extends Notifier { + @override + bool build() => false; + + Future maybeMigrate() async { + if (state) return; + if (!ref.read(isCloudCollabEnabledProvider)) return; + + final api = ref.read(cloudMigrationApiProvider); + final folders = Hive.box(HiveBoxNames.foldersBox).values.toList(); + final strategies = + Hive.box(HiveBoxNames.strategiesBox).values.toList(); + var migrationSucceeded = true; + + Future recordFailure({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + migrationSucceeded = false; + log( + 'Cloud migration write failed at $source: $error', + name: 'cloud_migration', + error: error, + stackTrace: stackTrace, + ); + await _maybeReportCloudUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + } + + for (final folder in folders) { + try { + await api.createFolder( + publicId: folder.id, + name: folder.name, + parentFolderPublicId: folder.parentID, + iconId: folder.iconId, + ); + } catch (error, stackTrace) { + await recordFailure( + source: 'cloud_migration:create_folder', + error: error, + stackTrace: stackTrace, + ); + } + } + + for (final storedStrategy in strategies) { + late final StrategyData strategy; + try { + strategy = StrategyMigrator.migrateToCurrentVersion(storedStrategy); + if (strategy != storedStrategy) { + await Hive.box(HiveBoxNames.strategiesBox) + .put(strategy.id, strategy); + } + } catch (error, stackTrace) { + await recordFailure( + source: 'cloud_migration:migrate_local_strategy', + error: error, + stackTrace: stackTrace, + ); + continue; + } + final pages = [...strategy.pages] + ..sortBySortIndex((item) => item.sortIndex); + final firstPage = pages.isNotEmpty ? pages.first : null; + final fallbackPageId = const Uuid().v4(); + try { + await api.createStrategyWithInitialPage( + publicId: strategy.id, + name: strategy.name, + mapData: Maps.mapNames[strategy.mapData] ?? 'ascent', + initialPagePublicId: firstPage?.id ?? fallbackPageId, + initialPageName: firstPage?.name ?? 'Page 1', + initialPageIsAutoNamed: + firstPage == null ? true : firstPage.isAutoNamed, + initialPageIsAttack: firstPage?.isAttack ?? true, + initialPageSettings: firstPage == null + ? ref.read(strategySettingsProvider).toJson() + : firstPage.settings.toJson(), + folderPublicId: strategy.folderID, + themeProfileId: strategy.themeProfileId, + themeOverridePalette: strategy.themeOverridePalette?.toJson(), + ); + } catch (error, stackTrace) { + await recordFailure( + source: 'cloud_migration:create_strategy', + error: error, + stackTrace: stackTrace, + ); + } + + final allOps = []; + final usedElementIds = {}; + final usedLineupIds = {}; + for (var i = 0; i < pages.length; i++) { + final page = pages[i]; + if (i == 0) { + appendMigratedPageOps( + allOps, + page, + usedElementIds: usedElementIds, + usedLineupIds: usedLineupIds, + ); + continue; + } + try { + await api.addPage( + strategyPublicId: strategy.id, + pagePublicId: page.id, + name: page.name, + isAutoNamed: page.isAutoNamed, + sortIndex: page.sortIndex, + isAttack: page.isAttack, + settings: page.settings.toJson(), + expectedRevision: i - 1, + ); + } catch (error, stackTrace) { + await recordFailure( + source: 'cloud_migration:add_page', + error: error, + stackTrace: stackTrace, + ); + } + + appendMigratedPageOps( + allOps, + page, + usedElementIds: usedElementIds, + usedLineupIds: usedLineupIds, + ); + } + + if (allOps.isNotEmpty) { + try { + final acknowledgements = await api.applyBatch( + strategyPublicId: strategy.id, + clientId: const Uuid().v4(), + ops: allOps, + ); + final rejected = acknowledgements.where((ack) => !ack.isAck).toList(); + if (acknowledgements.length != allOps.length || rejected.isNotEmpty) { + final rejectionReasons = rejected + .map((ack) => ack.reason ?? ack.status) + .toSet() + .join(', '); + await recordFailure( + source: 'cloud_migration:apply_batch', + error: StateError( + 'Cloud migration batch was not fully acknowledged' + '${rejectionReasons.isEmpty ? '' : ': $rejectionReasons'}', + ), + stackTrace: StackTrace.current, + ); + } + } catch (error, stackTrace) { + await recordFailure( + source: 'cloud_migration:apply_batch', + error: error, + stackTrace: stackTrace, + ); + } + } + } + + state = migrationSucceeded; + } + + Future _maybeReportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return; + } + + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + } +} diff --git a/lib/providers/collab/cloud_sync_status_provider.dart b/lib/providers/collab/cloud_sync_status_provider.dart new file mode 100644 index 00000000..082b5d26 --- /dev/null +++ b/lib/providers/collab/cloud_sync_status_provider.dart @@ -0,0 +1,75 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +enum CloudSyncStatus { synced, editing, syncing, offline, attention } + +/// Whether some cloud work may not be on this device: an outbox could not be +/// read, or a write to it could not be confirmed. That work may live only in +/// memory, so this outranks every other sync state, the server refusing this +/// build included: nothing may suggest throwing the session away. +final cloudWorkDurabilityUncertainProvider = Provider((ref) { + final opQueueState = ref.watch(strategyOpQueueProvider); + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + return opQueueState.loadIssues.isNotEmpty || + opQueueState.hasDurabilityFailure || + mediaQueueState.loadIssues.isNotEmpty || + mediaQueueState.durabilityError != null; +}); + +final cloudSyncStatusProvider = Provider((ref) { + final saveState = ref.watch(strategySaveStateProvider); + final opQueueState = ref.watch(strategyOpQueueProvider); + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + final strategy = ref.watch(strategyProvider); + final activeMediaJobs = mediaQueueState.jobsForStrategy( + strategy.source == StrategySource.cloud ? strategy.strategyId : null, + ); + final accountMediaErrorCount = + mediaQueueState.jobs.where((job) => job.isFailed).length; + final hasTextDrafts = ref.watch( + textDraftProvider.select((drafts) => drafts.isNotEmpty), + ); + final isConnected = ref.watch(convexConnectionProvider).valueOrNull ?? true; + + if (ref.watch(cloudWorkDurabilityUncertainProvider)) { + return CloudSyncStatus.attention; + } + // The server refuses this build: nothing syncs until it is reloaded or + // updated, whatever the queue holds. + if (ref.watch(clientUpgradeRequiredProvider)) { + return CloudSyncStatus.attention; + } + if (opQueueState.needsAttention || + opQueueState.accountOutbox.needsAttention || + saveState.mediaSyncErrorCount > 0 || + accountMediaErrorCount > 0) { + return CloudSyncStatus.attention; + } + if (!isConnected) { + return CloudSyncStatus.offline; + } + if (saveState.cloudSyncError != null) { + return CloudSyncStatus.attention; + } + if (hasTextDrafts) { + return CloudSyncStatus.editing; + } + if (saveState.isSaving || + saveState.hasPendingCloudSync || + saveState.hasPendingMediaSync || + activeMediaJobs.isNotEmpty || + opQueueState.accountOutbox.hasWork || + mediaQueueState.jobs.isNotEmpty || + !opQueueState.durableLoaded || + !mediaQueueState.durableLoaded) { + return CloudSyncStatus.syncing; + } + return CloudSyncStatus.synced; +}); diff --git a/lib/providers/collab/convex_connection_provider.dart b/lib/providers/collab/convex_connection_provider.dart new file mode 100644 index 00000000..a3d6a9f1 --- /dev/null +++ b/lib/providers/collab/convex_connection_provider.dart @@ -0,0 +1,23 @@ +import 'package:icarus/collab/convex_client.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// Whether the Convex WebSocket is connected right now, for code that checks +/// once before sending. It is the latest value of [convexConnectionProvider], +/// so it follows every change; before the stream's first event it asks the +/// client directly. (A snapshot read once and cached would keep whatever it +/// saw first: on web the socket is still opening at startup, so it said +/// offline forever and no edit was ever sent.) +final convexConnectionSnapshotProvider = Provider((ref) { + return ref.watch(convexConnectionProvider).valueOrNull ?? + ConvexClient.instance.isConnected; +}); + +/// Reactive view of the Convex WebSocket connection, seeded with the current +/// state so watchers don't flash "offline" while the stream warms up. +final convexConnectionProvider = StreamProvider((ref) async* { + final client = ConvexClient.instance; + yield client.isConnected; + await for (final state in client.connectionState) { + yield state == WebSocketConnectionState.connected; + } +}); diff --git a/lib/providers/collab/lineup_conflicts_provider.dart b/lib/providers/collab/lineup_conflicts_provider.dart new file mode 100644 index 00000000..9ae437f0 --- /dev/null +++ b/lib/providers/collab/lineup_conflicts_provider.dart @@ -0,0 +1,125 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; + +/// A lineup group whose change the server refused, and what each side did +/// to it since the version both started from. +class LineupGroupConflict { + const LineupGroupConflict({ + required this.key, + required this.yours, + required this.cloud, + }); + + final EntitySyncKey key; + + /// The user's waiting version against the version it was made from, or, + /// when [cloud] is null, against the cloud's version. + final List yours; + + /// The cloud's version against the one the user's was made from. Null + /// when that version is no longer on this device (the page was drawn + /// again since): then who changed what cannot be told apart. + final List? cloud; +} + +/// What the active strategy's refused work does to its lineup groups, when +/// that work is all lineup groups on the page on screen: the case Keep both +/// covers. Null otherwise, and the sync popover offers Keep mine and Use +/// cloud alone. +/// +/// Every version is read from what this device already has: the user's +/// newest waiting change, the server's version the canvas was drawn from, +/// and the live copy of the page. The version drawn from tells the sides' +/// changes apart only while it is the one the refused change was made +/// against; once the page is drawn again it is the cloud's, so every +/// conflict then lists only how the user's version differs from the +/// cloud's. When a version cannot be read, there is nothing trustworthy to +/// show, so this is null too. +final lineupConflictsProvider = Provider?>((ref) { + final queue = ref.watch(strategyOpQueueProvider); + final attention = queue.attentionByEntityKey; + final page = ref.watch(remoteEditorSnapshotProvider).valueOrNull?.activePage; + // Keep both saves a copy; while outbox records cannot be saved it is not + // offered. + if (attention.isEmpty || page == null || queue.hasDurabilityFailure) { + return null; + } + final pageId = page.page.publicId; + if (attention.keys.any( + (key) => key.kind != EntitySyncKeyKind.lineup || key.pageId != pageId, + )) { + return null; + } + + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + final cloudRows = {for (final row in page.lineups) row.publicId: row}; + final versions = <(EntitySyncKey, LineUpGraph, LineUpGraph, LineUpGraph)>[]; + var basesHold = true; + for (final MapEntry(:key, value: refused) in attention.entries) { + final base = liveSync.hydratedBase(key); + final refusedOp = refused.pending.op; + final madeAgainst = switch (refusedOp) { + LineupAddOp(:final expectedLineupRevision) => expectedLineupRevision, + LineupPatchOp(:final expectedLineupRevision) => expectedLineupRevision, + LineupDeleteOp(:final expectedLineupRevision) => expectedLineupRevision, + LineupReorderOp(:final expectedLineupRevision) => expectedLineupRevision, + _ => null, + }; + if (base?.revision != madeAgainst) basesHold = false; + final newest = (queue.successorByEntityKey[key] ?? refused).pending.op; + final yoursPayload = switch (newest) { + LineupAddOp(:final payload) => payload, + LineupPatchOp(:final payload?) => payload, + LineupDeleteOp() => null, + _ => base?.payload, + }; + final cloudRow = cloudRows[key.entityId]; + final yours = _groupGraph(key, yoursPayload); + final cloud = _groupGraph( + key, + cloudRow == null || cloudRow.deleted ? null : cloudRow.payload, + ); + final from = + base == null ? LineUpGraph.empty : _groupGraph(key, base.payload); + if (yours == null || cloud == null || from == null) return null; + versions.add((key, yours, cloud, from)); + } + final conflicts = [ + for (final (key, yours, cloud, from) in versions) + basesHold + ? LineupGroupConflict( + key: key, + yours: lineupChanges(from: from, to: yours), + cloud: lineupChanges(from: from, to: cloud), + ) + : LineupGroupConflict( + key: key, + yours: lineupChanges(from: cloud, to: yours), + cloud: null, + ), + ]; + return conflicts; +}); + +/// The lineups one group row's [payload] holds; empty for no row, null when +/// the row cannot be read. +LineUpGraph? _groupGraph(EntitySyncKey key, Object? payload) { + if (payload == null) return LineUpGraph.empty; + try { + return lineUpGraphFromCloudRows([ + CloudLineupRow( + publicId: key.entityId!, + payload: cloudObjectPayload(payload), + ), + ]).graph; + } on FormatException { + return null; + } +} diff --git a/lib/providers/collab/lineup_editing_presence_provider.dart b/lib/providers/collab/lineup_editing_presence_provider.dart new file mode 100644 index 00000000..2bb34d07 --- /dev/null +++ b/lib/providers/collab/lineup_editing_presence_provider.dart @@ -0,0 +1,87 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; + +/// The lineups and spots each open lineup dialog shows, by the dialog that +/// opened them, so having one open counts as editing it. +final openLineUpItemsProvider = + NotifierProvider>>( + OpenLineUpItemsNotifier.new, +); + +class OpenLineUpItemsNotifier extends Notifier>> { + /// The page each dialog opened on: its items are that page's, and stay + /// so if the page on screen changes while it is open. + final Map _pageOf = {}; + + @override + Map> build() => const {}; + + void open(Object owner, Set itemIds) { + _pageOf[owner] = ref.read(strategyPageSessionProvider).activePageId; + state = {...state, owner: itemIds}; + } + + void close(Object owner) { + _pageOf.remove(owner); + if (!state.containsKey(owner)) return; + state = {...state}..remove(owner); + } + + /// The page [owner] opened on. + String? pageOf(Object owner) => _pageOf[owner]; +} + +/// The lineup groups this user is editing on the page on screen: those of +/// the lineup spots they hold, the spot a lineup is being placed from, and +/// the lineups an open dialog shows. Null for none. +final myLineupEditingProvider = Provider((ref) { + final pageId = + ref.watch(strategyPageSessionProvider.select((s) => s.activePageId)); + if (pageId == null) return null; + final held = ref.watch(editorHeldEntitiesProvider) ?? const {}; + final placement = ref.watch(lineUpProvider.select((s) => s.placement)); + final open = ref.watch(openLineUpItemsProvider); + final openNotifier = ref.read(openLineUpItemsProvider.notifier); + ref.watch(lineupGroupMemoryRevisionProvider); + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + final groupIds = { + for (final id in { + ...held, + if (placement?.pinnedOriginId case final id?) id, + if (placement?.pinnedLandingId case final id?) id, + for (final MapEntry(key: owner, value: ids) in open.entries) + if (openNotifier.pageOf(owner) == pageId) ...ids, + }) + if (liveSync.lineupGroupOf(pageId, id) case final group?) group, + }; + if (groupIds.isEmpty) return null; + return PresenceEditing(pageId: pageId, groupIds: groupIds); +}); + +/// Teammates editing the lineup group [itemId] (a lineup, origin or landing +/// on the page on screen) is in, one entry per person. +final lineupGroupEditorsProvider = + Provider.autoDispose.family, String>((ref, itemId) { + final pageId = + ref.watch(strategyPageSessionProvider.select((s) => s.activePageId)); + if (pageId == null) return const []; + ref.watch(lineupGroupMemoryRevisionProvider); + final group = ref + .read(activePageLiveSyncProvider.notifier) + .lineupGroupOf(pageId, itemId); + if (group == null) return const []; + // Cursors move many times a second; rebuild only when who is editing + // changes, by watching a key of them rather than the list itself. + ref.watch(strategyPresenceProvider.select((s) => jsonEncode([ + for (final peer in s.editingLineupGroup(pageId, group)) + [peer.uid, peer.name], + ]))); + return ref.read(strategyPresenceProvider).editingLineupGroup(pageId, group); +}); diff --git a/lib/providers/collab/media_bytes_source.dart b/lib/providers/collab/media_bytes_source.dart new file mode 100644 index 00000000..9734f3aa --- /dev/null +++ b/lib/providers/collab/media_bytes_source.dart @@ -0,0 +1,150 @@ +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/services/local_image_file.dart'; + +/// The bytes one cloud media upload sends. +sealed class MediaBytesSource { + const MediaBytesSource(); + + Future length(); + Stream> openRead(); +} + +/// The image file in the strategy's image folder (desktop). File access +/// stays in the native half of `local_image_file.dart`. +final class FileMediaBytes extends MediaBytesSource { + const FileMediaBytes(this.path); + final String path; + + @override + Future length() => localImageFileLength(path); + + @override + Stream> openRead() => readLocalImageFile(path); +} + +/// Bytes held for an image that is not a file on this device (web). +final class MemoryMediaBytes extends MediaBytesSource { + const MemoryMediaBytes(this.bytes); + final Uint8List bytes; + + @override + Future length() async => bytes.length; + + @override + Stream> openRead() => Stream.value(bytes); +} + +/// Whether this device keeps strategy images as files. Where it does not +/// (web), a picked image waits in [pendingMediaBytesProvider] until it has +/// uploaded. Override with false to exercise the web path in tests. +final imageFilesOnDeviceProvider = Provider((ref) => deviceHasImageFiles); + +final pendingMediaBytesStoreProvider = Provider( + (ref) => ref.watch(imageFilesOnDeviceProvider) + ? MemoryPendingMediaBytesStore() + : HivePendingMediaBytesStore(), +); + +/// The largest image a cloud strategy takes: the server's default +/// `R2_MAX_IMAGE_BYTES` (`convex/lib/r2.ts`). Checked before bytes are kept, +/// so the browser never stores an image that can never upload. +const maxCloudImageBytes = 15 * 1024 * 1024; + +class MediaTooLargeException implements Exception { + const MediaTooLargeException(this.byteSize); + final int byteSize; + + String get userMessage => 'This image is over 15 MB. Pick a smaller one.'; + + @override + String toString() => + 'Image is $byteSize bytes; the limit is $maxCloudImageBytes.'; +} + +final pendingMediaBytesProvider = + NotifierProvider>( + PendingMediaBytesNotifier.new, +); + +/// Image bytes, by [pendingMediaStorageKey], for images that are not files +/// on this device and not yet painted from the cloud. The editor paints them +/// while they upload; the media upload queue sends them. +class PendingMediaBytesNotifier extends Notifier> { + /// A draft (a lineup image picked but not saved) has no upload job, and + /// another tab of this browser may still be editing it. Only a draft this + /// old is taken as abandoned. + static const abandonedAfter = Duration(days: 7); + + late PendingMediaBytesStore _store; + // Served from the cloud URL while the upload was still attaching. + final Set _servedFromCloud = {}; + + @override + Map build() { + _store = ref.watch(pendingMediaBytesStoreProvider); + _servedFromCloud.clear(); + return Map.unmodifiable({ + for (final record in _store.load()) + pendingMediaStorageKey(record.key): record.bytes, + }); + } + + Uint8List? bytesFor(PendingMediaKey key) => + state[pendingMediaStorageKey(key)]; + + /// Saves [bytes] before any upload job names [key], so no job ever waits + /// on bytes that were never stored. + Future put(PendingMediaKey key, Uint8List bytes) async { + if (bytes.length > maxCloudImageBytes) { + throw MediaTooLargeException(bytes.length); + } + await _store.put( + PendingMediaRecord(key: key, bytes: bytes, savedAt: DateTime.now()), + ); + final storageKey = pendingMediaStorageKey(key); + _servedFromCloud.remove(storageKey); + state = Map.unmodifiable({...state, storageKey: bytes}); + } + + /// The upload attached, so a reload no longer needs these bytes. The + /// editor keeps painting them until the cloud URL arrives, unless it + /// already has. + Future markAttached(PendingMediaKey key) async { + await _store.remove(key); + final storageKey = pendingMediaStorageKey(key); + if (_servedFromCloud.remove(storageKey)) _forget(storageKey); + } + + /// The page serves [key]'s image from its cloud URL while its upload is + /// still attaching. The bytes go when the attach lands. + void markServedFromCloud(PendingMediaKey key) { + final storageKey = pendingMediaStorageKey(key); + if (state.containsKey(storageKey)) _servedFromCloud.add(storageKey); + } + + Future remove(PendingMediaKey key) async { + await _store.remove(key); + _forget(pendingMediaStorageKey(key)); + } + + /// Drops stored bytes that no upload job needs and that are older than + /// [abandonedAfter], so a draft another tab is editing survives. + Future pruneAbandoned({ + required bool Function(PendingMediaKey key) hasJob, + }) async { + final cutoff = DateTime.now().subtract(abandonedAfter); + for (final record in _store.load()) { + if (hasJob(record.key) || record.savedAt.isAfter(cutoff)) continue; + await remove(record.key); + } + } + + void _forget(String storageKey) { + _servedFromCloud.remove(storageKey); + if (!state.containsKey(storageKey)) return; + state = Map.unmodifiable({...state}..remove(storageKey)); + } +} diff --git a/lib/providers/collab/remote_library_provider.dart b/lib/providers/collab/remote_library_provider.dart new file mode 100644 index 00000000..5b7645ec --- /dev/null +++ b/lib/providers/collab/remote_library_provider.dart @@ -0,0 +1,133 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +final cloudFolderTreeProvider = + StreamProvider.autoDispose>((ref) async* { + final isCloud = ref.watch(isCloudCollabEnabledProvider); + final auth = ref.watch(authProvider); + if (!isCloud || auth.hasActiveAuthIncident) { + yield const []; + return; + } + + final authNotifier = ref.read(authProvider.notifier); + final repo = ref.watch(convexStrategyRepositoryProvider); + try { + await for (final folders in repo.watchAllFolders()) { + yield folders; + } + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + unawaited( + authNotifier.reportConvexUnauthenticated( + source: 'remote_library:folder_tree', + error: error, + stackTrace: stackTrace, + ), + ); + yield const []; + return; + } + rethrow; + } +}); + +// This is the same cached provider, retained for the widgets whose concern is +// the complete tree rather than the current folder's children. +final cloudAllFoldersProvider = cloudFolderTreeProvider; + +/// The folders at the open level of the cloud library, derived from the +/// tree. A plain derivation (not a second stream) so the tree never loses its +/// listener between rebuilds; an auto-disposed tree that re-subscribes on +/// every emission looks like a library that never finishes loading. +final cloudFoldersProvider = + Provider.autoDispose>>((ref) { + final section = ref.watch(cloudLibrarySectionProvider); + final parentFolderId = ref.watch(folderProvider); + final folderNotifier = ref.read(folderProvider.notifier); + return ref.watch(cloudFolderTreeProvider).whenData((allFolders) { + final wantsShared = section == CloudLibrarySection.sharedWithMe; + final scopedFolders = allFolders + .where((entry) => + wantsShared ? entry.role != 'owner' : entry.role == 'owner') + .toList(growable: false); + if (parentFolderId != null && + !scopedFolders.any((entry) => entry.folder.id == parentFolderId)) { + // The open folder is not in this scope (deleted elsewhere, or it is a + // local folder while My Library shows both stores). Clear the cloud + // slot once this build settles. + Future.microtask(() { + folderNotifier.updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + }); + return const []; + } + return scopedFolders + .where((entry) => entry.folder.parentID == parentFolderId) + .toList(growable: false); + }); +}); + +final cloudStrategiesProvider = + StreamProvider.autoDispose>((ref) async* { + final isCloud = ref.watch(isCloudCollabEnabledProvider); + final auth = ref.watch(authProvider); + if (!isCloud || auth.hasActiveAuthIncident) { + yield const []; + return; + } + + final section = ref.watch(cloudLibrarySectionProvider); + final folderId = ref.watch(folderProvider); + final folderNotifier = ref.read(folderProvider.notifier); + final authNotifier = ref.read(authProvider.notifier); + final repo = ref.watch(convexStrategyRepositoryProvider); + try { + final stream = section == CloudLibrarySection.sharedWithMe + ? (folderId == null + ? repo.watchSharedStrategies() + : repo.watchStrategiesForFolder(folderId, scope: 'shared')) + : repo.watchStrategiesForFolder(folderId, scope: 'owned'); + await for (final strategies in stream) { + yield strategies; + } + } catch (error, stackTrace) { + if (section != CloudLibrarySection.sharedWithMe && + _isInvalidFolderError(error)) { + folderNotifier.updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + yield const []; + return; + } + if (isConvexUnauthenticatedError(error)) { + unawaited( + authNotifier.reportConvexUnauthenticated( + source: 'remote_library:strategies', + error: error, + stackTrace: stackTrace, + ), + ); + yield const []; + return; + } + rethrow; + } +}); + +final cloudStrategyNamesProvider = Provider>((ref) { + final strategies = ref.watch(cloudStrategiesProvider).valueOrNull; + if (strategies == null) return const {}; + return { + for (final entry in strategies) entry.strategy.id: entry.strategy.name, + }; +}); + +bool _isInvalidFolderError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('folder not found') || message.contains('forbidden'); +} diff --git a/lib/providers/collab/remote_strategy_snapshot_provider.dart b/lib/providers/collab/remote_strategy_snapshot_provider.dart new file mode 100644 index 00000000..89fe3f34 --- /dev/null +++ b/lib/providers/collab/remote_strategy_snapshot_provider.dart @@ -0,0 +1,388 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_payload_upgrade.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; + +final remoteEditorSnapshotProvider = + AsyncNotifierProvider( + RemoteEditorSnapshotNotifier.new, +); + +/// Owns the editor's bounded live read set: one shell and one active page. +class RemoteEditorSnapshotNotifier + extends AsyncNotifier { + String? _activeStrategyPublicId; + String? _activePagePublicId; + + /// The share link the open strategy is read through, when the reader has + /// no access of their own (see [shareLinkViewProvider]). + String? _shareToken; + StreamSubscription? _shellSubscription; + StreamSubscription? _pageSubscription; + String? _subscribedPagePublicId; + Timer? _refreshDebounce; + int _pageEpoch = 0; + + /// Bumped by every read of the shell and page, and by a page selection: + /// a read that finishes after a newer one started changes nothing. + int _readEpoch = 0; + Map? _lastReconciledAssetsById; + + @override + Future build() async { + ref.onDispose(_disposeSubscriptions); + // The server accepts this build again: read what it refused. + ref.listen(clientUpgradeRequiredProvider, (previous, next) { + if (previous == true && !next) unawaited(refresh()); + }); + return null; + } + + String? get activeStrategyPublicId => _activeStrategyPublicId; + String? get activePagePublicId => _activePagePublicId; + + Future openStrategy( + String strategyPublicId, { + String? activePagePublicId, + }) async { + _disposeSubscriptions(); + _activeStrategyPublicId = strategyPublicId; + _activePagePublicId = activePagePublicId; + final linkView = ref.read(shareLinkViewProvider); + _shareToken = + linkView?.strategyPublicId == strategyPublicId ? linkView!.token : null; + _lastReconciledAssetsById = null; + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy( + strategyPublicId, + accountId: ref.read(authProvider).user?.id, + ); + state = const AsyncLoading(); + + await _refreshFromServer(); + await _startShellSubscription(strategyPublicId); + final pageId = _activePagePublicId; + if (pageId != null) { + await _startPageSubscription(strategyPublicId, pageId); + } + } + + Future setActivePage(String? pagePublicId) async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null || pagePublicId == _activePagePublicId) { + return state.valueOrNull?.activePage; + } + + _activePagePublicId = pagePublicId; + _readEpoch += 1; + final epoch = ++_pageEpoch; + await _pageSubscription?.cancel(); + _pageSubscription = null; + _subscribedPagePublicId = null; + + final current = state.valueOrNull; + if (current != null) { + state = AsyncData(current.copyWith(clearActivePage: true)); + } + if (pagePublicId == null) return null; + + try { + final page = + await ref.read(convexStrategyRepositoryProvider).fetchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + shareToken: _shareToken, + ); + if (epoch != _pageEpoch || pagePublicId != _activePagePublicId) { + return null; + } + _replacePage(page); + await _startPageSubscription(strategyPublicId, pagePublicId); + return state.valueOrNull?.activePage; + } catch (error, stackTrace) { + // A newer selection or read took over; its outcome stands. + if (epoch != _pageEpoch || pagePublicId != _activePagePublicId) { + return null; + } + _handleReadError( + source: 'remote_editor:page_refresh', + error: error, + stackTrace: stackTrace, + ); + return null; + } + } + + Future refresh() async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) return; + await _refreshFromServer(); + // A page whose selection failed, or that the refresh moved to, has no + // live read yet: a read that worked starts one. + final pageId = _activePagePublicId; + if (pageId != null && + pageId != _subscribedPagePublicId && + strategyPublicId == _activeStrategyPublicId && + state.valueOrNull?.activePage?.page.publicId == pageId) { + await _startPageSubscription(strategyPublicId, pageId); + } + } + + /// Shows [pagePublicId], which the server has just restored from its + /// trash, and watches it: read fresh along with the shell, since the shell + /// last watched may not list it yet. + Future showRestoredPage(String pagePublicId) async { + if (_activeStrategyPublicId == null) return; + _activePagePublicId = pagePublicId; + await refresh(); + } + + void clear() { + _activeStrategyPublicId = null; + _activePagePublicId = null; + _shareToken = null; + _lastReconciledAssetsById = null; + _disposeSubscriptions(); + ref.read(strategyOpQueueProvider.notifier).setActiveStrategy( + null, + accountId: ref.read(authProvider).user?.id, + ); + state = const AsyncData(null); + } + + Future _refreshFromServer() async { + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) return; + if (ref.read(authProvider).hasActiveAuthIncident) { + state = const AsyncData(null); + return; + } + + final epoch = ++_readEpoch; + try { + final repository = ref.read(convexStrategyRepositoryProvider); + final shell = await repository.fetchShell( + strategyPublicId, + shareToken: _shareToken, + ); + if (epoch != _readEpoch || _holdsNewerShell(shell)) return; + var pageId = _activePagePublicId; + if (pageId == null || + !shell.pages.any((page) => page.publicId == pageId)) { + pageId = shell.pages.firstOrNull?.publicId; + _activePagePublicId = pageId; + } + final page = pageId == null + ? null + : upgradeRemotePageSnapshot( + await repository.fetchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pageId, + shareToken: _shareToken, + ), + shell.header.mapData, + ); + if (epoch != _readEpoch || _holdsNewerShell(shell)) return; + state = AsyncData(RemoteEditorSnapshot(shell: shell, activePage: page)); + if (page != null) _reconcilePageMedia(page); + } catch (error, stackTrace) { + if (epoch != _readEpoch) return; + _handleReadError( + source: 'remote_editor:refresh', + error: error, + stackTrace: stackTrace, + ); + } + } + + /// Whether the shell held is newer than [shell]: a page just restored, say. + /// An older shell arriving late, from a read or the live one, must not + /// undo it. + bool _holdsNewerShell(RemoteStrategyShell shell) { + final current = state.valueOrNull; + return current != null && + current.header.publicId == shell.header.publicId && + shell.header.revision < current.header.revision; + } + + Future _startShellSubscription(String strategyPublicId) async { + await _shellSubscription?.cancel(); + _shellSubscription = ref + .read(convexStrategyRepositoryProvider) + .watchShell(strategyPublicId, shareToken: _shareToken) + .listen( + (shell) { + if (_activeStrategyPublicId != strategyPublicId || + ref.read(authProvider).hasActiveAuthIncident) return; + final current = state.valueOrNull; + if (_holdsNewerShell(shell)) return; + state = AsyncData(RemoteEditorSnapshot( + shell: shell, + activePage: current?.activePage, + )); + final activePageId = _activePagePublicId; + if (activePageId != null && + !shell.pages.any((page) => page.publicId == activePageId)) { + unawaited(setActivePage(shell.pages.firstOrNull?.publicId)); + } + }, + onError: (Object error, StackTrace stackTrace) => + _handleSubscriptionError( + source: 'remote_editor:shell_subscription', + error: error, + stackTrace: stackTrace, + ), + ); + } + + Future _startPageSubscription( + String strategyPublicId, + String pagePublicId, + ) async { + // Claimed before the await: a start that overlaps this one wins, and + // this one must not install a second watcher. + final epoch = ++_pageEpoch; + await _pageSubscription?.cancel(); + if (epoch != _pageEpoch || + _activeStrategyPublicId != strategyPublicId || + _activePagePublicId != pagePublicId) { + return; + } + _subscribedPagePublicId = pagePublicId; + _pageSubscription = ref + .read(convexStrategyRepositoryProvider) + .watchPageSnapshot( + strategyPublicId: strategyPublicId, + pagePublicId: pagePublicId, + shareToken: _shareToken, + ) + .listen( + (page) { + if (epoch != _pageEpoch || + _activeStrategyPublicId != strategyPublicId || + _activePagePublicId != pagePublicId || + ref.read(authProvider).hasActiveAuthIncident) return; + _replacePage(page); + }, + onError: (Object error, StackTrace stackTrace) => + _handleSubscriptionError( + source: 'remote_editor:page_subscription', + error: error, + stackTrace: stackTrace, + ), + ); + } + + void _replacePage(RemotePageSnapshot page) { + final current = state.valueOrNull; + if (current == null || page.page.publicId != _activePagePublicId) return; + state = AsyncData(current.copyWith( + activePage: upgradeRemotePageSnapshot(page, current.header.mapData), + )); + _reconcilePageMedia(page); + } + + void _reconcilePageMedia(RemotePageSnapshot page) { + if (!_shouldReconcilePageMedia(page.assetsById)) return; + final strategyPublicId = _activeStrategyPublicId; + if (strategyPublicId == null) return; + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).reconcilePageMedia( + strategyPublicId: strategyPublicId, + placedImages: ref.read(placedImageProvider).images, + assetsById: page.assetsById, + ), + ); + } + + bool _shouldReconcilePageMedia(Map next) { + final previous = _lastReconciledAssetsById; + if (previous != null && previous.length == next.length) { + var same = true; + for (final entry in next.entries) { + final old = previous[entry.key]; + if (old == null || + old.publicId != entry.value.publicId || + old.url != entry.value.url || + old.uploadStatus != entry.value.uploadStatus) { + same = false; + break; + } + } + if (same) return false; + } + _lastReconciledAssetsById = Map.unmodifiable(next); + return true; + } + + void _handleReadError({ + required String source, + required Object error, + required StackTrace stackTrace, + }) { + if (_readsAsSignedIn && isConvexUnauthenticatedError(error)) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + )); + state = const AsyncData(null); + return; + } + ref.read(clientUpgradeRequiredProvider.notifier).noteError(error); + log('Remote editor read failed: $error', + name: 'remote_editor', error: error, stackTrace: stackTrace); + state = AsyncError(error, stackTrace); + } + + void _handleSubscriptionError({ + required String source, + required Object error, + StackTrace? stackTrace, + }) { + if (_readsAsSignedIn && isConvexUnauthenticatedError(error)) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + )); + return; + } + log('Remote editor subscription failed: $error', + name: 'remote_editor', error: error, stackTrace: stackTrace); + // A read again would be refused again; the sync button says why. + if (ref.read(clientUpgradeRequiredProvider.notifier).noteError(error)) { + return; + } + _refreshDebounce?.cancel(); + _refreshDebounce = Timer( + const Duration(milliseconds: 120), + () => unawaited(_refreshFromServer()), + ); + } + + /// Whether a refused read means the session broke. A signed-out reader on + /// a share link has no session to repair: their refusals are the link's + /// (disabled, or never theirs), and the editor shows those itself. + bool get _readsAsSignedIn => + _shareToken == null || ref.read(authProvider).isAuthenticated; + + void _disposeSubscriptions() { + _refreshDebounce?.cancel(); + _refreshDebounce = null; + _pageEpoch += 1; + unawaited(_shellSubscription?.cancel()); + unawaited(_pageSubscription?.cancel()); + _shellSubscription = null; + _pageSubscription = null; + _subscribedPagePublicId = null; + } +} diff --git a/lib/providers/collab/strategy_capabilities_provider.dart b/lib/providers/collab/strategy_capabilities_provider.dart new file mode 100644 index 00000000..5f3fe98b --- /dev/null +++ b/lib/providers/collab/strategy_capabilities_provider.dart @@ -0,0 +1,62 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +export 'package:icarus/collab/strategy_capabilities.dart'; + +final currentStrategyCapabilitiesProvider = + Provider((ref) { + final strategy = ref.watch( + strategyProvider.select((value) => (value.source, value.strategyId)), + ); + if (strategy.$1 != StrategySource.cloud) { + return StrategyCapabilities.fullAccess(); + } + final header = ref.watch(remoteEditorSnapshotProvider).valueOrNull?.header; + final role = header?.publicId == strategy.$2 ? header?.role : null; + return StrategyCapabilities.fromCloudRole(role); +}); + +/// Whether the open strategy is view-only for this reader, by the last role +/// the server reported. The editor leaves out its editing panels then, since +/// none of their controls would respond. Layout reads the last known role so +/// a refresh that briefly fails cannot drop an editor's panels mid-session. +final isViewOnlyStrategyProvider = Provider((ref) { + final source = ref.watch(strategyProvider.select((value) => value.source)); + return source == StrategySource.cloud && + ref.watch(lastKnownCloudRoleProvider) == 'viewer'; +}); + +/// Last non-null cloud role reported for the currently open strategy. +/// +/// [remoteEditorSnapshotProvider] transiently loses its value during +/// reloads, refresh errors, and auth incidents, so role-dependent UI (like +/// the "View only" note on the cloud sync button) must not read +/// `valueOrNull` directly or it flickers off mid-session. This provider +/// remembers the last role seen for the open strategy and only resets when a +/// different strategy is opened. +/// It is null only before the role has ever been known. +final lastKnownCloudRoleProvider = + NotifierProvider( + LastKnownCloudRoleNotifier.new, +); + +class LastKnownCloudRoleNotifier extends Notifier { + @override + String? build() { + // Rebuild (and therefore reset the cached role) whenever a different + // strategy is opened. + ref.watch(strategyProvider.select((value) => value.strategyId)); + + ref.listen(remoteEditorSnapshotProvider, (previous, next) { + final role = next.valueOrNull?.header.role; + if (role != null) { + state = role; + } + }); + + return ref.read(remoteEditorSnapshotProvider).valueOrNull?.header.role; + } +} diff --git a/lib/providers/collab/strategy_conflict_provider.dart b/lib/providers/collab/strategy_conflict_provider.dart new file mode 100644 index 00000000..aa5c4921 --- /dev/null +++ b/lib/providers/collab/strategy_conflict_provider.dart @@ -0,0 +1,26 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; + +final strategyConflictProvider = + NotifierProvider>( + StrategyConflictNotifier.new, +); + +class StrategyConflictNotifier extends Notifier> { + @override + List build() { + return const []; + } + + void push(ConflictResolution resolution) { + state = [...state, resolution]; + } + + void clear(String opId) { + state = state.where((item) => item.opId != opId).toList(growable: false); + } + + void clearAll() { + state = const []; + } +} diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart new file mode 100644 index 00000000..14aacf0c --- /dev/null +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -0,0 +1,2934 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:math' as math; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:uuid/uuid.dart'; + +class StrategyOutboxSession { + const StrategyOutboxSession({ + required this.accountId, + required this.isReady, + required this.hasAuthIncident, + }); + + final String? accountId; + final bool isReady; + final bool hasAuthIncident; +} + +final strategyOutboxSessionProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return StrategyOutboxSession( + accountId: auth.user?.id, + isReady: auth.isAuthenticated && auth.isConvexUserReady, + hasAuthIncident: auth.hasActiveAuthIncident, + ); +}); + +class StrategyOutboxSummary { + const StrategyOutboxSummary({ + required this.strategyPublicId, + required this.queuedCount, + required this.inFlightCount, + required this.pausedCount, + required this.attentionCount, + required this.successorCount, + this.reason, + }); + + final String strategyPublicId; + final int queuedCount; + final int inFlightCount; + final int pausedCount; + final int attentionCount; + final int successorCount; + final String? reason; + + int get workCount => + queuedCount + + inFlightCount + + pausedCount + + attentionCount + + successorCount; + bool get hasRunnableWork => queuedCount > 0 || inFlightCount > 0; + bool get needsAttention => pausedCount > 0 || attentionCount > 0; +} + +class AccountStrategyOutboxSummary { + const AccountStrategyOutboxSummary({ + this.accountId, + this.strategies = const {}, + this.deletedStrategies = const {}, + }); + + final String? accountId; + + /// Work that can still reach the server, by strategy. + final Map strategies; + + /// Unsent changes to strategies the server no longer has, counted by + /// strategy. They wait for the user to discard them and never count as + /// work, syncing, or attention for the strategies that still exist. + final Map deletedStrategies; + + int get workCount => strategies.values.fold( + 0, + (total, strategy) => total + strategy.workCount, + ); + int get strategyCount => strategies.length; + bool get hasWork => workCount > 0; + bool get hasRunnableWork => + strategies.values.any((strategy) => strategy.hasRunnableWork); + bool get needsAttention => + strategies.values.any((strategy) => strategy.needsAttention); +} + +class StrategyOpQueueState { + const StrategyOpQueueState({ + this.accountId, + this.strategyPublicId, + this.clientId, + this.queuedByEntityKey = const {}, + this.inFlightByEntityKey = const {}, + this.successorByEntityKey = const {}, + this.pausedByEntityKey = const {}, + this.attentionByEntityKey = const {}, + this.loadIssues = const [], + this.durableLoaded = false, + this.hasDurabilityFailure = false, + this.isFlushing = false, + this.lastError, + this.lastFlushAt, + this.lastAcks = const [], + this.lastAckBatch = const [], + this.accountOutbox = const AccountStrategyOutboxSummary(), + }); + + final String? accountId; + final String? strategyPublicId; + final String? clientId; + final Map queuedByEntityKey; + final Map inFlightByEntityKey; + final Map successorByEntityKey; + final Map pausedByEntityKey; + final Map attentionByEntityKey; + final List loadIssues; + final bool durableLoaded; + + /// A write in this process has not been verified. Persisted, unreadable + /// records are reported separately by [loadIssues]. + final bool hasDurabilityFailure; + final bool isFlushing; + final String? lastError; + final DateTime? lastFlushAt; + final List lastAcks; + final List lastAckBatch; + final AccountStrategyOutboxSummary accountOutbox; + + bool get needsAttention => + loadIssues.isNotEmpty || + hasDurabilityFailure || + pausedByEntityKey.isNotEmpty || + attentionByEntityKey.isNotEmpty; + + bool get outboxIsReliable => + durableLoaded && loadIssues.isEmpty && !hasDurabilityFailure; + + List get pending => [ + ...queuedByEntityKey.values.map((intent) => intent.pending), + ...inFlightByEntityKey.values.map((intent) => intent.pending), + ...successorByEntityKey.values.map((intent) => intent.pending), + ...pausedByEntityKey.values.map((intent) => intent.pending), + ...attentionByEntityKey.values.map((intent) => intent.pending), + ]; + + StrategyOpQueueState copyWith({ + Map? queuedByEntityKey, + Map? inFlightByEntityKey, + Map? successorByEntityKey, + Map? pausedByEntityKey, + Map? attentionByEntityKey, + bool? hasDurabilityFailure, + bool? isFlushing, + String? lastError, + bool clearError = false, + DateTime? lastFlushAt, + List? lastAcks, + List? lastAckBatch, + AccountStrategyOutboxSummary? accountOutbox, + }) { + return StrategyOpQueueState( + accountId: accountId, + strategyPublicId: strategyPublicId, + clientId: clientId, + queuedByEntityKey: queuedByEntityKey ?? this.queuedByEntityKey, + inFlightByEntityKey: inFlightByEntityKey ?? this.inFlightByEntityKey, + successorByEntityKey: successorByEntityKey ?? this.successorByEntityKey, + pausedByEntityKey: pausedByEntityKey ?? this.pausedByEntityKey, + attentionByEntityKey: attentionByEntityKey ?? this.attentionByEntityKey, + loadIssues: loadIssues, + durableLoaded: durableLoaded, + hasDurabilityFailure: hasDurabilityFailure ?? this.hasDurabilityFailure, + isFlushing: isFlushing ?? this.isFlushing, + lastError: clearError ? null : (lastError ?? this.lastError), + lastFlushAt: lastFlushAt ?? this.lastFlushAt, + lastAcks: lastAcks ?? this.lastAcks, + lastAckBatch: lastAckBatch ?? this.lastAckBatch, + accountOutbox: accountOutbox ?? this.accountOutbox, + ); + } +} + +final strategyOpQueueProvider = + NotifierProvider( + StrategyOpQueueNotifier.new, +); + +final pendingStrategyOpsProvider = Provider>((ref) { + return ref.watch(strategyOpQueueProvider).pending.map((op) => op.op).toList(); +}); + +class StrategyOpQueueNotifier extends Notifier { + static const int _maxBatchSize = 40; + static const int _maxAttempts = 8; + static const Duration _debounceDelay = Duration(milliseconds: 180); + static const Duration _busyBackgroundRetryDelay = Duration(seconds: 1); + Timer? _debounceTimer; + Timer? _retryTimer; + Timer? _backgroundRetryTimer; + int _offlineRetryCount = 0; + bool _networkBusy = false; + ({String accountId, String strategyPublicId})? _drainingStrategy; + bool _isDisposed = false; + late DurableStrategyOutboxStore _store; + late Map _recordsByStorageKey; + final Set _awaitingRemoteAdoption = {}; + + /// Op IDs of the work the open canvas wrote: queued from its edits since + /// it was last drawn fresh, or resent in their place. Any other op's ack + /// is marked restored, since the canvas was drawn without it. Cleared by + /// [forgetCanvasWork]. + final Set _canvasOpIds = {}; + int _canvasSession = 0; + final Set _uncertainOversizedParking = {}; + final Set _uncertainDurableRecords = {}; + final Map _uncertainDurableIntents = {}; + String? _uncertainOversizedParkingMessage; + Future _writeTail = Future.value(); + + ConvexStrategyRepository get _repo => + ref.read(convexStrategyRepositoryProvider); + + @override + StrategyOpQueueState build() { + _isDisposed = false; + _store = ref.read(durableStrategyOutboxStoreProvider); + final loaded = _store.load(); + _recordsByStorageKey = { + for (final record in loaded.records) + record.storageKey: _retiredAsAttention(_resumedAfterUpgrade(record)), + }; + ref.onDispose(() { + _isDisposed = true; + _retryTimer?.cancel(); + _debounceTimer?.cancel(); + _backgroundRetryTimer?.cancel(); + }); + ref.listen(strategyOutboxSessionProvider, + (previous, next) { + if (previous?.accountId != next.accountId) { + setCurrentAccount(next.accountId); + } + final becameReady = !(previous?.isReady ?? false) && next.isReady; + final recovered = + (previous?.hasAuthIncident ?? false) && !next.hasAuthIncident; + if (becameReady || recovered) { + retryCurrentAccount(); + } + }); + // The server accepts this build again: the work it held goes out. + ref.listen(clientUpgradeRequiredProvider, (previous, next) { + if (previous == true && !next) retryCurrentAccount(); + }); + ref.listen>(convexConnectionProvider, (previous, next) { + if (previous?.valueOrNull != true && next.valueOrNull == true) { + _scheduleBackgroundDrain(ignoreBackoff: true); + } + }); + final session = ref.read(strategyOutboxSessionProvider); + if (session.accountId != null && + loaded.records.any((record) => record.accountId == session.accountId)) { + _backgroundRetryTimer = Timer( + Duration.zero, + () => retryCurrentAccount(), + ); + } + return StrategyOpQueueState( + accountId: session.accountId, + clientId: const Uuid().v4(), + loadIssues: loaded.issues, + durableLoaded: true, + hasDurabilityFailure: false, + lastError: loaded.issues.isEmpty + ? null + : 'The cloud outbox contains unreadable saved work.', + accountOutbox: _accountSummary(session.accountId), + ); + } + + /// [record] waiting in attention when it holds a lineup change in the + /// cloud format before one row per lineup, whatever state an older build + /// left it in (queued, paused, refused for another reason). It can never + /// be sent; in attention it is shown, with its reason, and nothing but the + /// user's discard removes it: reconciling the canvas's work never touches + /// attention. Like [_resumedAfterUpgrade], only the copy in memory changes. + static DurableOutboxRecord _retiredAsAttention(DurableOutboxRecord record) { + if (!isRetiredCloudLineupOp(record.pending.op) || + (record.status == DurableOutboxStatus.attention && + record.lastError == retiredLineupOpMessage)) { + return record; + } + return record.copyWith( + status: DurableOutboxStatus.attention, + lastError: retiredLineupOpMessage, + ); + } + + /// [record] as this build sends it. Work a build the server refused kept + /// failing through no fault of its own, and may have been paused for it; + /// it goes back in the queue with no failures counted. Only the copy in + /// memory changes: the next write of the record saves it, and until then + /// every start resumes it again. + static DurableOutboxRecord _resumedAfterUpgrade(DurableOutboxRecord record) { + if (!isClientUpgradeRequiredReason(record.lastError) || + (record.status != DurableOutboxStatus.paused && + record.status != DurableOutboxStatus.queued)) { + return record; + } + return record.copyWith( + status: DurableOutboxStatus.queued, + pending: PendingOp( + op: record.pending.op, + clientId: record.pending.clientId, + ), + clearError: true, + ); + } + + void setCurrentAccount(String? accountId) { + if (state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } + setActiveStrategy(null, accountId: accountId); + } + + void setActiveStrategy( + String? strategyPublicId, { + required String? accountId, + }) { + if (state.strategyPublicId == strategyPublicId && + state.accountId == accountId) { + _publishAccountSummary(); + _scheduleBackgroundDrain(ignoreBackoff: true); + return; + } + + _debounceTimer?.cancel(); + _retryTimer?.cancel(); + _awaitingRemoteAdoption.clear(); + _offlineRetryCount = 0; + final matching = accountId == null || strategyPublicId == null + ? const [] + : _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId) + .toList(growable: false); + final queued = {}; + final inFlight = {}; + final successors = {}; + final paused = {}; + final attention = {}; + for (final record in matching) { + final intent = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + switch (record.status) { + case DurableOutboxStatus.queued: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.inFlight: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else if (_drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId)) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + // An interrupted request is replayed with its original op/client + // id after restart. + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.paused: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + paused[record.entityKey] = intent; + } + case DurableOutboxStatus.attention: + attention[record.entityKey] = intent; + } + } + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + } + final clientId = + matching.firstOrNull?.pending.clientId ?? const Uuid().v4(); + final hasDurabilityFailure = _hasDurabilityFailureForAccount(accountId); + state = StrategyOpQueueState( + accountId: accountId, + strategyPublicId: strategyPublicId, + clientId: clientId, + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + successorByEntityKey: successors, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + loadIssues: state.loadIssues, + durableLoaded: true, + hasDurabilityFailure: hasDurabilityFailure, + lastError: hasDurabilityFailure + ? (_uncertainOversizedParkingMessage ?? + 'Cloud work could not be verified in the durable outbox.') + : _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + accountId: accountId, + strategyPublicId: strategyPublicId, + ), + accountOutbox: _accountSummary(accountId), + ); + if (queued.isNotEmpty && inFlight.isEmpty) { + _scheduleFlush(flushImmediately: true); + } + _scheduleBackgroundDrain(ignoreBackoff: true); + } + + /// The canvas dropped everything it drew and is drawn fresh from the + /// server (a strategy opened or left, local ones included): no work + /// already queued is on it any more, so every ack from here on is marked + /// restored until the canvas queues new work. + void forgetCanvasWork() { + _canvasSession++; + _canvasOpIds.clear(); + } + + /// How many op IDs are remembered as the canvas's work; for tests. + int get canvasWorkCount => _canvasOpIds.length; + + Future enqueue( + StrategyOp op, { + bool flushImmediately = false, + }) { + final entityKey = EntitySyncKey.forStrategyOp(op); + if (entityKey == null) return Future.value(); + final pageId = entityKey.pageId; + if (pageId != null) { + return syncDesiredOpsForPage( + pageId: pageId, + desiredOpsByEntityKey: {entityKey: op}, + clearMissing: false, + flushImmediately: flushImmediately, + ); + } + return syncDesiredGenericOp( + entityKey: entityKey, + desiredOp: op, + flushImmediately: flushImmediately, + ); + } + + Future enqueueAll( + Iterable ops, { + bool flushImmediately = false, + }) async { + final byPage = >{}; + for (final op in ops) { + final key = EntitySyncKey.forStrategyOp(op); + if (key == null) continue; + if (key.pageId == null) { + await syncDesiredGenericOp(entityKey: key, desiredOp: op); + } else { + (byPage[key.pageId!] ??= {})[key] = op; + } + } + for (final entry in byPage.entries) { + await syncDesiredOpsForPage( + pageId: entry.key, + desiredOpsByEntityKey: entry.value, + clearMissing: false, + ); + } + _scheduleFlush(flushImmediately: flushImmediately); + } + + Future syncDesiredGenericOp({ + required EntitySyncKey entityKey, + required StrategyOp? desiredOp, + bool flushImmediately = false, + }) { + final canvasSession = _canvasSession; + return _serializeWrite(() => _syncDesiredLocked( + keys: {entityKey}, + desiredOps: {entityKey: desiredOp}, + flushImmediately: flushImmediately, + canvasSession: canvasSession, + )); + } + + Future syncDesiredOpsForPage({ + required String pageId, + required Map desiredOpsByEntityKey, + bool clearMissing = true, + bool flushImmediately = false, + }) { + final canvasSession = _canvasSession; + // A desired op the queue already holds is work kept as it is (live sync + // keeps recovered work this way), not a new edit. Read now: by the time + // this write runs it may have landed and left the queue. + final heldOpIds = {for (final pending in state.pending) pending.op.opId}; + return _serializeWrite(() async { + final keys = clearMissing + ? { + ...state.queuedByEntityKey.keys + .where((key) => key.pageId == pageId), + ...state.pausedByEntityKey.keys + .where((key) => key.pageId == pageId), + ...state.inFlightByEntityKey.keys + .where((key) => key.pageId == pageId), + ...state.successorByEntityKey.keys + .where((key) => key.pageId == pageId), + ...desiredOpsByEntityKey.keys, + } + : desiredOpsByEntityKey.keys.toSet(); + await _syncDesiredLocked( + keys: keys, + desiredOps: { + for (final key in keys) key: desiredOpsByEntityKey[key], + }, + flushImmediately: flushImmediately, + canvasSession: canvasSession, + heldOpIds: heldOpIds, + ); + }); + } + + /// [canvasSession] is the canvas's session when the desired ops were + /// asked for: ops made from them are the canvas's work only if it has not + /// been drawn fresh since. A desired op in [heldOpIds] was already queued + /// then, so it is not the canvas's work; if it has landed or been + /// replaced since, whatever the queue now holds for its entity is left as + /// it is. + Future _syncDesiredLocked({ + required Set keys, + required Map desiredOps, + required bool flushImmediately, + required int canvasSession, + Set heldOpIds = const {}, + }) async { + void writtenByCanvas(PendingOp pending, StrategyOp desired) { + if (canvasSession == _canvasSession && + !heldOpIds.contains(desired.opId)) { + _canvasOpIds.add(pending.op.opId); + } + } + + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || strategyPublicId == null) { + if (desiredOps.values.any((op) => op != null)) { + state = state.copyWith( + lastError: + 'Cloud work could not be queued without an active account.', + hasDurabilityFailure: true, + ); + } + return; + } + + final queued = Map.from( + state.queuedByEntityKey, + ); + final paused = Map.from( + state.pausedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + var changed = false; + try { + for (final key in keys) { + if (_awaitingRemoteAdoption.contains(key)) continue; + final desired = desiredOps[key]; + final existing = queued[key]; + final inFlightIntent = state.inFlightByEntityKey[key]; + final inFlight = inFlightIntent?.pending.op; + final successorIntent = successors[key]; + final pausedIntent = paused[key]; + final attentionIntent = attention[key]; + // Kept work that has landed or been replaced since is not written + // again. + if (desired != null && + heldOpIds.contains(desired.opId) && + ![ + existing?.pending, + inFlightIntent?.pending, + successorIntent?.pending, + pausedIntent?.pending, + attentionIntent?.pending, + ].any((pending) => pending?.op.opId == desired.opId)) { + continue; + } + + // A rejected op remains the durable authority until the user + // explicitly retries it. Reconciliation may update its successor, but + // it must never make rejected work eligible for an automatic flush. + if (attentionIntent != null) { + if (desired == null) continue; + final storageKey = DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + ); + final current = + _recordForActiveKey(key) ?? _uncertainDurableIntents[storageKey]; + if (current == null) { + throw StateError('Durable attention record is missing for $key.'); + } + if (_sameIntent(attentionIntent.pending.op, desired)) { + final hasUncertainDurableRecord = + _uncertainDurableRecords.contains(storageKey); + if (successorIntent != null || hasUncertainDurableRecord) { + final recoveredOversizedParking = + _uncertainOversizedParking.contains(storageKey); + final isOversized = + cloudOperationExceedsPolicy(current.pending.op); + final requeues = hasUncertainDurableRecord && !isOversized; + // Dropping only the successor leaves the refused change as it + // was, so it keeps the server's reason for refusing it. + await _putRecord(current.copyWith( + status: isOversized + ? DurableOutboxStatus.attention + : (requeues ? DurableOutboxStatus.queued : current.status), + clearSuccessorPending: true, + updatedAt: DateTime.now(), + lastError: isOversized ? cloudOperationTooLargeMessage : null, + clearError: requeues || + current.lastError == cloudOperationTooLargeMessage, + )); + if (recoveredOversizedParking) { + _uncertainOversizedParking.remove(storageKey); + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + } + successors.remove(key); + if (hasUncertainDurableRecord && !isOversized) { + attention.remove(key); + queued[key] = attentionIntent; + } + changed = true; + } + continue; + } + if (successorIntent != null && + _sameIntent(successorIntent.pending.op, desired)) { + continue; + } + final pending = PendingOp( + op: successorIntent == null + ? desired + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, + clientId: successorIntent?.pending.clientId ?? + attentionIntent.pending.clientId, + ); + writtenByCanvas(pending, desired); + final recoveredOversizedParking = + _uncertainOversizedParking.contains(current.storageKey); + await _putRecord(current.copyWith( + status: DurableOutboxStatus.attention, + successorPending: pending, + updatedAt: DateTime.now(), + lastError: recoveredOversizedParking + ? cloudOperationTooLargeMessage + : null, + )); + if (recoveredOversizedParking) { + _uncertainOversizedParking.remove(current.storageKey); + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + } + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: pending, + ); + changed = true; + continue; + } + + if (desired == null) { + if (inFlight != null || successorIntent != null) { + continue; + } + final current = existing ?? pausedIntent; + if (current != null) { + await _removeRecordIfCurrent(key, current.pending.op.opId); + queued.remove(key); + paused.remove(key); + attention.remove(key); + changed = true; + } + continue; + } + + if (inFlight != null && _sameIntent(desired, inFlight)) { + if (successorIntent != null) { + await _putRecord(_recordFor( + key: key, + pending: inFlightIntent!.pending, + status: DurableOutboxStatus.inFlight, + clearSuccessorPending: true, + )); + successors.remove(key); + changed = true; + } + continue; + } + + if (inFlightIntent != null) { + if (successorIntent != null && + _sameIntent(successorIntent.pending.op, desired)) { + continue; + } + final pending = PendingOp( + op: successorIntent == null + ? desired + : _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, + clientId: successorIntent?.pending.clientId ?? state.clientId!, + ); + writtenByCanvas(pending, desired); + await _putRecord(_recordFor( + key: key, + pending: inFlightIntent.pending, + status: DurableOutboxStatus.inFlight, + successorPending: pending, + )); + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: pending, + ); + queued.remove(key); + changed = true; + continue; + } + + if (existing != null && successorIntent != null) { + if (_sameIntent(existing.pending.op, desired)) { + await _putRecord(_recordFor( + key: key, + pending: existing.pending, + status: DurableOutboxStatus.queued, + clearSuccessorPending: true, + )); + successors.remove(key); + changed = true; + continue; + } + if (_sameIntent(successorIntent.pending.op, desired)) { + continue; + } + final pending = PendingOp( + op: _mergeQueuedIntent(successorIntent.pending.op, desired) ?? + desired, + clientId: successorIntent.pending.clientId, + ); + writtenByCanvas(pending, desired); + await _putRecord(_recordFor( + key: key, + pending: existing.pending, + status: DurableOutboxStatus.queued, + successorPending: pending, + )); + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: pending, + ); + changed = true; + continue; + } + + if (existing != null && _sameIntent(existing.pending.op, desired)) { + continue; + } + + final base = pausedIntent ?? existing; + final merged = base == null + ? desired + : _mergeQueuedIntent(base.pending.op, desired); + if (merged == null) { + if (base != null) { + await _removeRecordIfCurrent(key, base.pending.op.opId); + queued.remove(key); + paused.remove(key); + attention.remove(key); + changed = true; + } + continue; + } + + final pending = PendingOp( + op: merged, + clientId: base?.pending.clientId ?? state.clientId!, + attempts: base?.pending.attempts ?? 0, + lastAttemptAt: base?.pending.lastAttemptAt, + ); + writtenByCanvas(pending, desired); + final record = _recordFor( + key: key, + pending: pending, + status: cloudOperationExceedsPolicy(pending.op) + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + lastError: cloudOperationExceedsPolicy(pending.op) + ? cloudOperationTooLargeMessage + : null, + ); + await _putRecord(record); + final intent = QueuedEntityIntent(entityKey: key, pending: pending); + if (record.status == DurableOutboxStatus.attention) { + attention[key] = intent; + queued.remove(key); + } else { + queued[key] = intent; + attention.remove(key); + } + paused.remove(key); + changed = true; + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + if (!changed) return; + _pruneCanvasOpIds(); + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ); + state = state.copyWith( + queuedByEntityKey: queued, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: attentionMessage, + clearError: attentionMessage == null, + ); + _scheduleFlush(flushImmediately: flushImmediately); + } + + void clearStaleError() { + if (state.lastError == null || + state.isFlushing || + state.pending.isNotEmpty || + state.loadIssues.isNotEmpty) return; + state = state.copyWith(clearError: true); + } + + Future retryPaused({bool flushImmediately = true}) { + return _serializeWrite(() async { + if (state.pausedByEntityKey.isEmpty) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + try { + for (final entry in state.pausedByEntityKey.entries) { + final pending = PendingOp( + op: entry.value.pending.op, + clientId: entry.value.pending.clientId, + ); + final isOversized = cloudOperationExceedsPolicy(pending.op); + await _putRecord(_recordFor( + key: entry.key, + pending: pending, + status: isOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + lastError: isOversized ? cloudOperationTooLargeMessage : null, + )); + final intent = + QueuedEntityIntent(entityKey: entry.key, pending: pending); + if (isOversized) { + attention[entry.key] = intent; + queued.remove(entry.key); + } else { + queued[entry.key] = intent; + } + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + state = state.copyWith( + queuedByEntityKey: queued, + pausedByEntityKey: const {}, + attentionByEntityKey: attention, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + clearError: attention.isEmpty && state.loadIssues.isEmpty, + ); + _scheduleFlush(flushImmediately: flushImmediately); + }); + } + + /// Rebases server-rejected intents only after an explicit user action. + /// + /// The server revision is stored with the durable attention record, so the + /// same recovery remains available after an app restart. Ordinary page + /// reconciliation never removes these records. + Future retryRejected({bool flushImmediately = true}) => + _retryAttention(flushImmediately: flushImmediately); + + /// Sends again, as they were, the changes to [pageId] the server refused + /// while the page was in its trash: it has just been restored. Each goes + /// under a new op id, since the server answers a known one as it did + /// before. + Future retryRestoredPage(String pageId) => retryRestoredPages({pageId}); + + /// Re-sends the edits refused because their page was deleted, for the pages + /// in [livePageIds], which are live again (restored here or by anyone). + Future retryRestoredPages(Set livePageIds) => _retryAttention( + flushImmediately: true, + only: (key, record) => + livePageIds.contains(key.pageId) && + record?.lastError == pageDeletedMessage, + ); + + /// Retries the attention records [only] accepts, or all of them. + Future _retryAttention({ + required bool flushImmediately, + bool Function(EntitySyncKey key, DurableOutboxRecord? record)? only, + }) { + return _serializeWrite(() async { + if (state.attentionByEntityKey.isEmpty) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + var changed = false; + try { + for (final entry in state.attentionByEntityKey.entries) { + final record = _recordForActiveKey(entry.key); + if (only != null && !only(entry.key, record)) continue; + final rejected = entry.value.pending; + final rejectedOp = rejected.op; + final successor = record?.successorPending; + final retryOp = successor?.op ?? rejectedOp; + // No server takes a lineup change in the old cloud format; sending + // it again would only bring it back here. + if (isRetiredCloudLineupOp(retryOp)) continue; + final isPayloadPolicyAttention = + record?.lastError == cloudOperationTooLargeMessage || + cloudOperationExceedsPolicy(rejectedOp); + // Nothing moved on the server: a change to a page in the trash is + // re-sent as it was once the page is restored. + final isOnDeletedPage = record?.lastError == pageDeletedMessage; + final retriesAsSent = isPayloadPolicyAttention || isOnDeletedPage; + final retryRevision = + record?.latestServerRevision ?? rejectedOp.expectedRevision; + if (!retriesAsSent && retryRevision == null) continue; + final isTombstoneRestore = + (retryOp is ElementAddOp || retryOp is LineupAddOp) && + (record?.lastError == 'missing_expected_revision' || + record?.lastError == 'revision_mismatch'); + // A teammate deleted what this change edits. Keeping mine brings + // it back as the user has it: an add over the tombstone. + final restoresDeleted = + record?.lastError == OpRejectionReason.deleted.wireName; + final StrategyOp rebasedOp; + if (retriesAsSent) { + rebasedOp = retryOp.withOpId(const Uuid().v4()); + } else if (restoresDeleted) { + final restore = _restoreOp(retryOp, retryRevision!); + if (restore == null) continue; + rebasedOp = restore; + } else { + rebasedOp = _rebaseRejectedOp( + retryOp, + retryRevision!, + preserveAdd: isTombstoneRestore, + ); + } + _keepCanvasWritten(retryOp, rebasedOp); + final pending = PendingOp( + op: rebasedOp, + clientId: successor?.clientId ?? rejected.clientId, + ); + final isRetryOversized = cloudOperationExceedsPolicy(pending.op); + await _putRecord(_recordFor( + key: entry.key, + pending: pending, + status: isRetryOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + clearSuccessorPending: true, + lastError: isRetryOversized ? cloudOperationTooLargeMessage : null, + )); + _uncertainOversizedParking.remove( + DurableOutboxRecord.createStorageKey( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: entry.key, + ), + ); + final intent = QueuedEntityIntent( + entityKey: entry.key, + pending: pending, + ); + if (isRetryOversized) { + attention[entry.key] = intent; + queued.remove(entry.key); + } else { + queued[entry.key] = intent; + attention.remove(entry.key); + } + successors.remove(entry.key); + changed = true; + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + if (!changed) { + if (only != null) return; + // Old-format lineup changes are skipped on purpose, and keep their + // own reason. + final onlyRetired = attention.values.every( + (intent) => isRetiredCloudLineupOp(intent.pending.op), + ); + // Changes a teammate's delete refused that hold nothing to bring + // the item back with. + final cannotRestore = attention.keys.any( + (key) => + _recordForActiveKey(key)?.lastError == + OpRejectionReason.deleted.wireName, + ); + state = state.copyWith( + lastError: onlyRetired + ? _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ) + : cannotRestore + ? teammateDeletedCannotRestoreMessage + : 'Some retained cloud work cannot be retried ' + 'automatically because the server has no matching ' + 'revision.', + ); + return; + } + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + _pruneCanvasOpIds(); + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + state = state.copyWith( + queuedByEntityKey: queued, + attentionByEntityKey: attention, + successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: attentionMessage, + clearError: attentionMessage == null, + ); + _scheduleFlush(flushImmediately: flushImmediately); + }); + } + + /// Discards selected server-rejected intents after an explicit user choice. + /// + /// Each durable record contains both the rejected predecessor and any newer + /// successor for that entity. Removing the record discards both, without + /// changing unrelated queued, in-flight, paused, or rejected work. + /// + /// Given [onlyIf] (the refused op id and successor op id of each key, as + /// the user last saw them), a key whose waiting work has changed since is + /// left as it is: the user chose to discard that work, not a newer edit. + Future> discardRejected( + Set entityKeys, { + Map? onlyIf, + }) => + _dropAttention(entityKeys, adoptRemote: true, onlyIf: onlyIf); + + /// Whether the server refused [key]'s waiting work because a teammate + /// deleted what it edits (rejection reason `deleted`). + bool refusedAsDeleted(EntitySyncKey key) => + _recordForActiveKey(key)?.lastError == OpRejectionReason.deleted.wireName; + + /// Drops the refused work of entities the user has since deleted on the + /// canvas, when the server refused it because a teammate deleted them + /// ([refusedAsDeleted]). Both sides agree they are gone, so nothing is + /// left to keep, and Keep mine must not bring them back. Work refused for + /// any other reason stays: only that refusal says the server deleted the + /// item, rather than never having it or holding it elsewhere. Unlike + /// [discardRejected] the canvas is already the truth: there is nothing to + /// adopt. + Future> settleAttention(Set entityKeys) => + _dropAttention( + { + for (final key in entityKeys) + if (refusedAsDeleted(key)) key + }, + adoptRemote: false, + ); + + Future> _dropAttention( + Set entityKeys, { + required bool adoptRemote, + Map? onlyIf, + }) { + return _serializeWrite(() async { + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = {}; + Object? persistenceError; + StackTrace? persistenceStackTrace; + + for (final key in entityKeys) { + final rejected = attention[key]; + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (rejected == null || accountId == null || strategyPublicId == null) { + continue; + } + if (onlyIf != null && + onlyIf[key] != + ( + rejected.pending.op.opId, + successors[key]?.pending.op.opId, + )) { + continue; + } + final storageKey = DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + ); + final hasUncertainParking = + _uncertainOversizedParking.contains(storageKey); + final hasUncertainDurableRecord = + _uncertainDurableRecords.contains(storageKey); + final record = _recordForActiveKey(key); + final hasMatchingAttentionRecord = record != null && + (record.status == DurableOutboxStatus.attention || + cloudOperationExceedsPolicy(record.pending.op)) && + record.pending.op.opId == rejected.pending.op.opId; + if (!hasUncertainParking && + !hasUncertainDurableRecord && + !hasMatchingAttentionRecord) { + continue; + } + try { + await _removeRecordByStorageKey(storageKey); + _uncertainOversizedParking.remove(storageKey); + attention.remove(key); + successors.remove(key); + if (adoptRemote) _awaitingRemoteAdoption.add(key); + discarded.add(key); + } catch (error, stackTrace) { + persistenceError = error; + persistenceStackTrace = stackTrace; + break; + } + } + + if (persistenceError != null) { + log( + 'Durable outbox persistence failed: $persistenceError', + name: 'strategy_outbox', + error: persistenceError, + stackTrace: persistenceStackTrace, + ); + } + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + _pruneCanvasOpIds(); + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + final errorMessage = persistenceError == null + ? attentionMessage + : 'Cloud work could not be removed from the durable outbox: ' + '$persistenceError'; + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: errorMessage, + clearError: errorMessage == null, + ); + return Set.unmodifiable(discarded); + }); + } + + void completeRemoteAdoption(Set entityKeys) { + _awaitingRemoteAdoption.removeAll(entityKeys); + } + + Future _parkOversizedQueuedOps() async { + if (_hasUncertainParkingForActiveStrategy) return false; + final oversized = state.queuedByEntityKey.entries + .where((entry) => cloudOperationExceedsPolicy(entry.value.pending.op)) + .toList(growable: false); + if (oversized.isEmpty) return true; + + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + Object? persistenceError; + StackTrace? persistenceStackTrace; + for (final entry in oversized) { + final record = _recordForActiveKey(entry.key); + if (record == null || + record.pending.op.opId != entry.value.pending.op.opId) { + persistenceError ??= + StateError('Durable queued record is missing for ${entry.key}.'); + queued.remove(entry.key); + attention[entry.key] = entry.value; + _uncertainOversizedParking.add( + DurableOutboxRecord.createStorageKey( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: entry.key, + ), + ); + continue; + } + // Keep an already-durable queued record untouched. The in-memory view + // and restart loader both classify it as attention, so parking never + // risks destroying the only recoverable copy with an overwrite. + queued.remove(entry.key); + attention[entry.key] = entry.value; + } + if (persistenceError != null) { + log( + 'Durable outbox persistence failed: $persistenceError', + name: 'strategy_outbox', + error: persistenceError, + stackTrace: persistenceStackTrace, + ); + _debounceTimer?.cancel(); + _debounceTimer = null; + _retryTimer?.cancel(); + _retryTimer = null; + _uncertainOversizedParkingMessage = + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent. The change still needs attention: ' + '$persistenceError'; + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: state.pausedByEntityKey, + attention: attention, + ); + state = state.copyWith( + queuedByEntityKey: queued, + attentionByEntityKey: attention, + hasDurabilityFailure: _hasDurabilityFailureForCurrentAccount, + lastError: persistenceError == null + ? attentionMessage + : _uncertainOversizedParkingMessage, + clearError: persistenceError == null && attentionMessage == null, + ); + return persistenceError == null; + } + + Future flushNow() async { + await _writeTail; + if (_isDisposed || _networkBusy) return; + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || + strategyPublicId == null || + state.queuedByEntityKey.isEmpty) { + return; + } + + if (!await _parkOversizedQueuedOps() || _isDisposed) return; + if (state.queuedByEntityKey.isEmpty) return; + + await _flushStrategy( + accountId: accountId, + strategyPublicId: strategyPublicId, + isBackground: false, + ); + } + + Future _flushStrategy({ + required String accountId, + required String strategyPublicId, + required bool isBackground, + bool ignoreBackoff = false, + }) async { + if (_isDisposed || _networkBusy) return; + + final mode = ref.read(cloudCollabModeProvider); + if (!mode.featureFlagEnabled || mode.forceLocalFallback) return; + final auth = ref.read(authProvider); + if (auth.hasActiveAuthIncident) { + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud auth incident active. Saved work is paused.', + ); + } + return; + } + // The server refuses this build, so nothing sent would land. The work + // waits, with no failure counted, for the reload or update that brings + // a build it accepts. + if (ref.read(clientUpgradeRequiredProvider)) { + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith(lastError: clientUpgradeRequiredQueueError); + } + return; + } + if (auth.user?.id != accountId) { + if (!isBackground && _isActive(accountId, strategyPublicId)) { + state = state.copyWith( + lastError: 'Cloud outbox belongs to a different account.', + ); + } + return; + } + if (!auth.isAuthenticated || + !auth.isConvexUserReady || + !ref.read(convexConnectionSnapshotProvider)) { + final message = !auth.isAuthenticated + ? 'Not authenticated for cloud sync.' + : (!auth.isConvexUserReady + ? 'Cloud user setup is not ready.' + : 'Cloud connection is offline.'); + if (!isBackground && _isActive(accountId, strategyPublicId)) { + _scheduleRetry( + state.queuedByEntityKey.values.map((item) => item.pending).toList(), + delay: _offlineRetryDelay(), + ); + if (state.lastError != message) { + AppErrorReporter.reportWarning( + 'Cloud sync is holding changes for strategy $strategyPublicId: ' + '$message', + source: 'cloud_sync.op_queue', + ); + } + state = state.copyWith(lastError: message); + } + return; + } + + _networkBusy = true; + _drainingStrategy = ( + accountId: accountId, + strategyPublicId: strategyPublicId, + ); + List batch; + var batchSucceeded = false; + try { + batch = await _claimBatch( + accountId: accountId, + strategyPublicId: strategyPublicId, + ignoreBackoff: ignoreBackoff || !isBackground, + ); + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + _finishNetworkLane(); + return; + } + if (batch.isEmpty) { + _finishNetworkLane(); + _scheduleBackgroundDrain(); + return; + } + if (_isDisposed) { + _finishNetworkLane(); + return; + } + // The claim awaited durable writes, and the account may have signed out + // or changed meanwhile. The batch belongs to the account that queued it, + // so it goes back to that account's queue, untouched, to send once it is + // signed in again. + if (!_canSendAs(accountId)) { + await _releaseClaimedRecords(batch); + _finishNetworkLane(); + _scheduleBackgroundDrain(); + return; + } + final batchClientId = batch.first.pending.clientId; + final isActiveStrategy = _isActive(accountId, strategyPublicId); + _refreshActiveQueueView( + isFlushing: isActiveStrategy, + clearError: isActiveStrategy, + ); + + try { + _retryTimer?.cancel(); + _retryTimer = null; + _offlineRetryCount = 0; + final acks = await _repo.applyBatch( + strategyPublicId: strategyPublicId, + clientId: batchClientId, + ops: batch.map((record) => record.pending.op).toList(growable: false), + // The transport can deliver this after a later sign-in (it resends + // pending requests on reconnect), so the server checks the batch's + // own account, not whoever is signed in then. + accountSubject: batch.first.accountId, + ); + await _applyAcksForRecords(batch, acks); + batchSucceeded = true; + } catch (error, stackTrace) { + // applyBatch raises NOT_FOUND only for the strategy itself (a missing + // page or element is a per-op result); the shell query confirms it is + // gone. Retrying could never succeed. + final strategyDeleted = isTypedConvexNotFoundError(error) && + await _strategyIsConfirmedDeleted(strategyPublicId); + if (strategyDeleted) { + await _resolveDeletedStrategy( + accountId: accountId, + strategyPublicId: strategyPublicId, + ); + } else { + final upgradeRequired = + ref.read(clientUpgradeRequiredProvider.notifier).noteError(error); + if (isConvexUnauthenticatedError(error)) { + unawaited(ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'strategy_op_queue:flush', + error: error, + stackTrace: stackTrace, + )); + } else { + AppErrorReporter.reportWarning( + 'Cloud sync could not send ${batch.length} change(s) for strategy ' + '$strategyPublicId', + source: 'cloud_sync.op_queue', + error: redactSyncDiagnosticText(error), + stackTrace: stackTrace, + ); + } + await _restoreRecordsAfterFailure( + batch, + lastError: + upgradeRequired ? clientUpgradeRequiredQueueError : '$error', + countsAsFailure: !upgradeRequired, + ); + } + } finally { + _finishNetworkLane(); + } + + if (!_isDisposed) { + if (state.queuedByEntityKey.isNotEmpty && + (isBackground || batchSucceeded)) { + unawaited(flushNow()); + } + _scheduleBackgroundDrain(); + } + } + + Future> _claimBatch({ + required String accountId, + required String strategyPublicId, + required bool ignoreBackoff, + }) { + return _serializeWrite(() async { + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false); + if (candidates.isEmpty) return const []; + final selected = []; + final batchClientId = candidates.first.pending.clientId; + for (final candidate in candidates) { + if (candidate.pending.clientId != batchClientId) continue; + if (selected.length >= _maxBatchSize) break; + final nextSelection = [...selected, candidate]; + final byteSize = serializedCloudBatchUtf8Bytes( + strategyPublicId: strategyPublicId, + clientId: batchClientId, + ops: nextSelection.map((record) => record.pending.op), + ); + if (byteSize > maxCloudBatchBytes) break; + selected.add(candidate); + } + final claimed = []; + for (final record in selected) { + final current = _recordsByStorageKey[record.storageKey]; + if (current == null || + current.pending.op.opId != record.pending.op.opId || + (current.status != DurableOutboxStatus.queued && + current.status != DurableOutboxStatus.inFlight)) { + continue; + } + final inFlight = current.copyWith( + status: DurableOutboxStatus.inFlight, + updatedAt: now, + clearError: true, + ); + await _putRecord(inFlight); + claimed.add(inFlight); + } + return claimed; + }); + } + + Future _applyAcksForRecords( + List batch, + List acks, + ) { + return _serializeWrite(() => _applyAcksForRecordsLocked(batch, acks)); + } + + Future _applyAcksForRecordsLocked( + List batch, + List acks, + ) async { + final byOpId = {for (final item in batch) item.pending.op.opId: item}; + final ackByOpId = {for (final ack in acks) ack.opId: ack}; + if (ackByOpId.length != batch.length || + !ackByOpId.keys.toSet().containsAll(byOpId.keys)) { + throw StateError( + 'Server returned an incomplete operation result batch.', + ); + } + for (final ack in acks) { + final sent = byOpId[ack.opId]; + if (sent == null) continue; + final current = _recordsByStorageKey[sent.storageKey]; + if (current?.pending.op.opId != ack.opId) continue; + final successor = current!.successorPending; + // Only an accepted predecessor establishes a revision for automatic + // promotion. A rejected predecessor leaves both intents in attention. + final successorRevision = ack.appliedRevision; + if (successor != null && ack.isAck && successorRevision != null) { + final predecessorCreatedEntity = + sent.pending.op is ElementAddOp || sent.pending.op is LineupAddOp; + final promoted = PendingOp( + op: _rebaseRejectedOp( + successor.op, + successorRevision, + preserveAdd: !predecessorCreatedEntity, + ), + clientId: successor.clientId, + ); + _keepCanvasWritten(successor.op, promoted.op); + final isPromotedOversized = cloudOperationExceedsPolicy(promoted.op); + await _putRecord(current.copyWith( + pending: promoted, + status: isPromotedOversized + ? DurableOutboxStatus.attention + : DurableOutboxStatus.queued, + updatedAt: DateTime.now(), + clearSuccessorPending: true, + lastError: isPromotedOversized ? cloudOperationTooLargeMessage : null, + clearError: !isPromotedOversized, + clearLatestServerRevision: true, + )); + } else if (successor != null) { + final retained = current.copyWith( + status: DurableOutboxStatus.attention, + updatedAt: DateTime.now(), + lastError: ack.reason ?? + 'The final change is waiting for conflict resolution.', + latestServerRevision: ack.latestRevision, + ); + await _putRecord(retained); + } else if (ack.isAck) { + await _removeRecordByStorageKeyIfCurrent(sent.storageKey, ack.opId); + } else { + AppErrorReporter.reportWarning( + 'The server rejected change ${ack.opId} for strategy ' + '${sent.strategyPublicId}', + source: 'cloud_sync.op_queue', + error: redactSyncDiagnosticText( + ack.reason ?? 'no reason given', + ), + ); + final rejected = current.copyWith( + status: DurableOutboxStatus.attention, + updatedAt: DateTime.now(), + lastError: ack.reason ?? 'The server rejected this change.', + latestServerRevision: ack.latestRevision, + ); + await _putRecord(rejected); + } + } + // Judged once the records are stored: the canvas may have been drawn + // fresh meanwhile, and then none of this is on it. + final acked = [ + for (final ack in acks) + if (byOpId[ack.opId] case final sent?) + AckedEntityIntent( + entityKey: sent.entityKey, + op: sent.pending.op, + ack: ack, + restored: !_canvasOpIds.contains(ack.opId), + ), + ]; + _pruneCanvasOpIds(); + if (_isDisposed) return; + final first = batch.first; + if (_isActive(first.accountId, first.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastAcks: acks, + lastAckBatch: acked, + lastFlushAt: DateTime.now(), + ); + } else { + _refreshActiveQueueView(); + } + } + + /// Whether the server confirms [strategyPublicId] is gone. Anything short + /// of a confirmation (offline, auth) is false, and the send is retried as + /// an ordinary failure. + Future _strategyIsConfirmedDeleted(String strategyPublicId) async { + try { + return await _repo.strategyIsDeleted(strategyPublicId); + } catch (_) { + return false; + } + } + + /// Settles every outbox record of a strategy the server no longer has. + /// If none of its changes carries anything the user authored (only + /// removals and reorders of a strategy that is gone), they are dropped + /// with a warning. Otherwise they stay on this device marked + /// [cloudStrategyDeletedMessage], never retried, until the user discards + /// them from the library. + Future _resolveDeletedStrategy({ + required String accountId, + required String strategyPublicId, + }) { + return _serializeWrite(() async { + final records = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId) + .toList(growable: false); + final keepsAuthoredWork = records.any( + (record) => + _carriesAuthoredContent(record.pending.op) || + (record.successorPending != null && + _carriesAuthoredContent(record.successorPending!.op)), + ); + try { + for (final record in records) { + if (keepsAuthoredWork) { + await _putRecord(record.copyWith( + status: DurableOutboxStatus.attention, + updatedAt: DateTime.now(), + lastError: cloudStrategyDeletedMessage, + )); + } else { + await _removeRecordByStorageKey(record.storageKey); + } + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } finally { + _pruneCanvasOpIds(); + } + AppErrorReporter.reportWarning( + keepsAuthoredWork + ? 'Strategy $strategyPublicId was deleted on the server; ' + '${records.length} unsent change(s) are kept on this device ' + 'until discarded.' + : 'Strategy $strategyPublicId was deleted on the server; dropped ' + '${records.length} unsent removal(s) that had nothing left to ' + 'remove.', + source: 'cloud_sync.op_queue', + ); + _refreshActiveQueueView(isFlushing: false); + }); + } + + /// Drops every outbox record of [strategyPublicId] for the current + /// account: a strategy the server no longer has, discarded by the user or + /// deleted by them on this device. + Future discardDeletedStrategy(String strategyPublicId) { + return _serializeWrite(() async { + final accountId = state.accountId; + if (accountId == null) return; + final storageKeys = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId) + .map((record) => record.storageKey) + .toList(growable: false); + try { + for (final storageKey in storageKeys) { + await _removeRecordByStorageKey(storageKey); + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } finally { + _pruneCanvasOpIds(); + } + _refreshActiveQueueView(); + }); + } + + /// Drops every outbox record of [pageId] in the active strategy, with any + /// successor it holds, including work whose first save could not be + /// verified: work for a page the server no longer has, which the user has + /// been told cannot be saved. An op in flight stays until the server + /// answers it; one marked in flight by a send that never finished (the app + /// closed mid-request) is only waiting to be replayed, and goes. Returns + /// whether nothing is left for the page. + Future discardDeletedPage(String pageId) async { + await _serializeWrite(() async { + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || strategyPublicId == null) return; + final isSending = _drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId); + final storageKeys = { + for (final record in [ + ..._recordsByStorageKey.values, + ..._uncertainDurableIntents.values, + ]) + if (record.accountId == accountId && + record.strategyPublicId == strategyPublicId && + record.entityKey.pageId == pageId && + !(isSending && record.status == DurableOutboxStatus.inFlight)) + record.storageKey, + }; + Object? persistenceError; + for (final storageKey in storageKeys) { + try { + await _removeRecordByStorageKey(storageKey); + _uncertainOversizedParking.remove(storageKey); + } catch (error, stackTrace) { + persistenceError = error; + log( + 'Durable outbox persistence failed: $error', + name: 'strategy_outbox', + error: error, + stackTrace: stackTrace, + ); + break; + } + } + if (_uncertainOversizedParking.isEmpty) { + _uncertainOversizedParkingMessage = null; + } + _pruneCanvasOpIds(); + // The error shown was about the dropped work or about what remains; + // what remains says it again. + _refreshActiveQueueView( + useProvidedError: true, + lastError: persistenceError == null + ? null + : 'Cloud work could not be removed from the durable outbox: ' + '$persistenceError', + ); + }); + final left = state; + return ![ + left.queuedByEntityKey, + left.inFlightByEntityKey, + left.successorByEntityKey, + left.pausedByEntityKey, + left.attentionByEntityKey, + ].any((intents) => intents.keys.any((key) => key.pageId == pageId)); + } + + /// Whether [op] carries something the user authored, as opposed to only + /// removing or reordering. Exhaustive, so a new op type must be placed. + static bool _carriesAuthoredContent(StrategyOp op) => switch (op) { + StrategyPatchOp() || + PageAddOp() || + PagePatchOp() || + PageContentPatchOp() || + ElementAddOp() || + ElementPatchOp() || + LineupAddOp() || + LineupPatchOp() => + true, + PageDeleteOp() || + PageReorderOp() || + ElementDeleteOp() || + ElementReorderOp() || + LineupDeleteOp() || + LineupReorderOp() => + false, + }; + + /// Whether [accountId] is the account signed in, ready, with no auth + /// incident: the only time its work may be sent. + bool _canSendAs(String accountId) { + final auth = ref.read(authProvider); + return !auth.hasActiveAuthIncident && + auth.user?.id == accountId && + auth.isAuthenticated && + auth.isConvexUserReady; + } + + /// Returns a claimed batch that was never sent to its account's queue: + /// same op ids, no attempt counted. + Future _releaseClaimedRecords(List batch) { + return _serializeWrite(() async { + try { + for (final claimed in batch) { + final current = _recordsByStorageKey[claimed.storageKey]; + if (current == null || + current.pending.op.opId != claimed.pending.op.opId || + current.status != DurableOutboxStatus.inFlight) { + continue; + } + await _putRecord(current.copyWith( + status: DurableOutboxStatus.queued, + updatedAt: DateTime.now(), + )); + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + _refreshActiveQueueView(); + }); + } + + /// Puts [batch] back in the queue after a send failed. Unless + /// [countsAsFailure] is false, each record counts a failure, and one that + /// has failed too often is paused until the user retries it. + Future _restoreRecordsAfterFailure( + List batch, { + required String lastError, + bool countsAsFailure = true, + }) { + return _serializeWrite( + () => _restoreRecordsAfterFailureLocked( + batch, + lastError: lastError, + countsAsFailure: countsAsFailure, + ), + ); + } + + Future _restoreRecordsAfterFailureLocked( + List batch, { + required String lastError, + required bool countsAsFailure, + }) async { + final retrying = []; + try { + for (final sent in batch) { + final current = _recordsByStorageKey[sent.storageKey]; + if (current == null || + current.pending.op.opId != sent.pending.op.opId) { + continue; + } + final pending = countsAsFailure + ? current.pending.incrementAttempt() + : current.pending; + final isPaused = pending.attempts >= _maxAttempts; + await _putRecord(current.copyWith( + pending: pending, + status: isPaused + ? DurableOutboxStatus.paused + : DurableOutboxStatus.queued, + updatedAt: DateTime.now(), + lastError: lastError, + )); + if (!isPaused) retrying.add(pending); + } + } catch (error, stackTrace) { + _recordPersistenceFailure(error, stackTrace); + return; + } + if (_isDisposed) return; + final draining = _drainingStrategy; + if (draining != null && + _isActive(draining.accountId, draining.strategyPublicId)) { + _refreshActiveQueueView( + isFlushing: false, + lastError: lastError, + useProvidedError: true, + ); + _scheduleRetry(retrying); + } else { + _refreshActiveQueueView(); + } + } + + DurableOutboxRecord _recordFor({ + required EntitySyncKey key, + required PendingOp pending, + required DurableOutboxStatus status, + PendingOp? successorPending, + bool clearSuccessorPending = false, + String? lastError, + }) { + final current = _recordForActiveKey(key); + final now = DateTime.now(); + return DurableOutboxRecord( + accountId: state.accountId!, + strategyPublicId: state.strategyPublicId!, + entityKey: key, + pending: pending, + status: status, + createdAt: current?.createdAt ?? now, + updatedAt: now, + successorPending: clearSuccessorPending + ? null + : (successorPending ?? current?.successorPending), + lastError: lastError, + ); + } + + DurableOutboxRecord? _recordForActiveKey(EntitySyncKey key) { + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + if (accountId == null || strategyPublicId == null) return null; + return _recordsByStorageKey[DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + )]; + } + + Future _putRecord(DurableOutboxRecord record) async { + try { + await _store.put(record); + _recordsByStorageKey[record.storageKey] = record; + _uncertainDurableRecords.remove(record.storageKey); + _uncertainDurableIntents.remove(record.storageKey); + } catch (_) { + _uncertainDurableRecords.add(record.storageKey); + _uncertainDurableIntents[record.storageKey] = record; + if (cloudOperationExceedsPolicy(record.pending.op)) { + _uncertainOversizedParking.add(record.storageKey); + _uncertainOversizedParkingMessage = + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.'; + } + _publishAccountSummary(); + rethrow; + } + _publishAccountSummary(); + } + + Future _removeRecordByStorageKey(String storageKey) async { + try { + await _store.remove(storageKey); + _recordsByStorageKey.remove(storageKey); + _uncertainDurableRecords.remove(storageKey); + _uncertainDurableIntents.remove(storageKey); + } catch (_) { + _uncertainDurableRecords.add(storageKey); + _publishAccountSummary(); + rethrow; + } + _publishAccountSummary(); + } + + Future _removeRecordIfCurrent( + EntitySyncKey key, + String opId, + ) async { + final record = _recordForActiveKey(key); + if (record == null || record.pending.op.opId != opId) return; + await _removeRecordByStorageKey(record.storageKey); + } + + Future _removeRecordByStorageKeyIfCurrent( + String storageKey, + String opId, + ) async { + final record = _recordsByStorageKey[storageKey]; + if (record == null || record.pending.op.opId != opId) return; + await _removeRecordByStorageKey(storageKey); + } + + bool _isActive(String accountId, String strategyPublicId) { + return state.accountId == accountId && + state.strategyPublicId == strategyPublicId; + } + + void _refreshActiveQueueView({ + bool? isFlushing, + String? lastError, + bool useProvidedError = false, + DateTime? lastFlushAt, + List? lastAcks, + List? lastAckBatch, + bool clearError = false, + }) { + if (_isDisposed) return; + final accountId = state.accountId; + final strategyPublicId = state.strategyPublicId; + final queued = {}; + final inFlight = {}; + final successors = {}; + final paused = {}; + final attention = {}; + if (accountId != null && strategyPublicId != null) { + final isActivelyDraining = _drainingStrategy == + (accountId: accountId, strategyPublicId: strategyPublicId); + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId || + record.strategyPublicId != strategyPublicId) { + continue; + } + final intent = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + switch (record.status) { + case DurableOutboxStatus.queued: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.inFlight: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else if (isActivelyDraining) { + inFlight[record.entityKey] = InFlightEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + sentAt: record.updatedAt, + ); + } else { + queued[record.entityKey] = intent; + } + case DurableOutboxStatus.paused: + if (cloudOperationExceedsPolicy(record.pending.op)) { + attention[record.entityKey] = intent; + } else { + paused[record.entityKey] = intent; + } + case DurableOutboxStatus.attention: + attention[record.entityKey] = intent; + } + } + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == accountId && + record.strategyPublicId == strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + final successor = record.successorPending; + if (successor != null) { + successors[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: successor, + ); + } + } + } + final attentionMessage = _loadedAttentionMessage( + loadIssues: state.loadIssues, + paused: paused, + attention: attention, + ); + final effectiveError = attentionMessage ?? + (useProvidedError ? lastError : (clearError ? null : state.lastError)); + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + successorByEntityKey: successors, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + accountOutbox: _accountSummary(accountId), + hasDurabilityFailure: _hasDurabilityFailureForAccount(accountId), + isFlushing: isFlushing, + lastError: effectiveError, + clearError: effectiveError == null, + lastFlushAt: lastFlushAt, + lastAcks: lastAcks, + lastAckBatch: lastAckBatch, + ); + } + + void _finishNetworkLane() { + _networkBusy = false; + _drainingStrategy = null; + if (!_isDisposed && state.isFlushing) { + _refreshActiveQueueView(isFlushing: false); + } + } + + AccountStrategyOutboxSummary _accountSummary(String? accountId) { + if (accountId == null) return const AccountStrategyOutboxSummary(); + final recordsByStorageKey = {}; + for (final record in _recordsByStorageKey.values) { + if (record.accountId != accountId) continue; + recordsByStorageKey[record.storageKey] = record; + } + for (final record in _uncertainDurableIntents.values) { + if (record.accountId != accountId) continue; + recordsByStorageKey[record.storageKey] = record; + } + + final storageKeysByStrategy = >{}; + for (final entry in recordsByStorageKey.entries) { + (storageKeysByStrategy[entry.value.strategyPublicId] ??= {}) + .add(entry.key); + } + for (final storageKey in { + ..._uncertainDurableRecords, + ..._uncertainOversizedParking, + }) { + final strategyPublicId = _strategyIdForStorageKey( + storageKey, + accountId: accountId, + ); + if (strategyPublicId != null) { + (storageKeysByStrategy[strategyPublicId] ??= {}) + .add(storageKey); + } + } + + final summaries = {}; + final deletedStrategies = {}; + for (final entry in storageKeysByStrategy.entries) { + final isDeleted = entry.value.any( + (storageKey) => + recordsByStorageKey[storageKey]?.lastError == + cloudStrategyDeletedMessage, + ); + if (isDeleted) { + deletedStrategies[entry.key] = entry.value.length; + continue; + } + var queuedCount = 0; + var inFlightCount = 0; + var pausedCount = 0; + var attentionCount = 0; + var successorCount = 0; + String? reason; + for (final storageKey in entry.value) { + final record = recordsByStorageKey[storageKey]; + final uncertain = _uncertainDurableRecords.contains(storageKey) || + _uncertainOversizedParking.contains(storageKey); + if (record == null) { + attentionCount += 1; + reason ??= 'Cloud work could not be verified in the durable outbox.'; + continue; + } + final oversized = cloudOperationExceedsPolicy(record.pending.op); + if (uncertain || oversized) { + attentionCount += 1; + reason ??= uncertain + ? 'Cloud work could not be verified in the durable outbox.' + : cloudOperationTooLargeMessage; + } else { + switch (record.status) { + case DurableOutboxStatus.queued: + queuedCount += 1; + case DurableOutboxStatus.inFlight: + inFlightCount += 1; + case DurableOutboxStatus.paused: + pausedCount += 1; + case DurableOutboxStatus.attention: + attentionCount += 1; + } + reason ??= record.lastError; + } + if (record.successorPending != null) successorCount += 1; + } + summaries[entry.key] = StrategyOutboxSummary( + strategyPublicId: entry.key, + queuedCount: queuedCount, + inFlightCount: inFlightCount, + pausedCount: pausedCount, + attentionCount: attentionCount, + successorCount: successorCount, + reason: reason, + ); + } + return AccountStrategyOutboxSummary( + accountId: accountId, + strategies: summaries, + deletedStrategies: deletedStrategies, + ); + } + + String? _strategyIdForStorageKey( + String storageKey, { + required String accountId, + }) { + final parts = storageKey.split('|'); + if (parts.length < 3 || Uri.decodeComponent(parts.first) != accountId) { + return null; + } + return Uri.decodeComponent(parts[1]); + } + + void _publishAccountSummary() { + if (_isDisposed) return; + final summary = _accountSummary(state.accountId); + final hasDurabilityFailure = _hasDurabilityFailureForCurrentAccount; + if (_sameAccountSummary(state.accountOutbox, summary) && + state.hasDurabilityFailure == hasDurabilityFailure) { + return; + } + state = state.copyWith( + accountOutbox: summary, + hasDurabilityFailure: hasDurabilityFailure, + ); + } + + bool _sameAccountSummary( + AccountStrategyOutboxSummary left, + AccountStrategyOutboxSummary right, + ) { + if (left.accountId != right.accountId || + left.strategies.length != right.strategies.length) { + return false; + } + for (final entry in left.strategies.entries) { + final other = right.strategies[entry.key]; + if (other == null || + other.queuedCount != entry.value.queuedCount || + other.inFlightCount != entry.value.inFlightCount || + other.pausedCount != entry.value.pausedCount || + other.attentionCount != entry.value.attentionCount || + other.successorCount != entry.value.successorCount || + other.reason != entry.value.reason) { + return false; + } + } + return true; + } + + Future _serializeWrite(Future Function() action) { + final next = _writeTail.then((_) => action()); + _writeTail = next.then((_) {}).catchError( + (Object error, StackTrace stackTrace) { + log('Outbox write failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + }, + ); + return next; + } + + bool _hasUncertainParkingFor({ + required String? accountId, + required String? strategyPublicId, + }) { + if (accountId == null || strategyPublicId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|'; + return _uncertainOversizedParking.any((key) => key.startsWith(prefix)); + } + + bool get _hasUncertainParkingForActiveStrategy => _hasUncertainParkingFor( + accountId: state.accountId, + strategyPublicId: state.strategyPublicId, + ); + + bool _hasUncertainDurableRecordFor({ + required String? accountId, + required String? strategyPublicId, + }) { + if (accountId == null || strategyPublicId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|' + '${Uri.encodeComponent(strategyPublicId)}|'; + return _uncertainDurableRecords.any((key) => key.startsWith(prefix)); + } + + bool get _hasUncertainDurableRecordForActiveStrategy => + _hasUncertainDurableRecordFor( + accountId: state.accountId, + strategyPublicId: state.strategyPublicId, + ); + + bool _hasDurabilityFailureForAccount(String? accountId) { + if (accountId == null) return false; + final prefix = '${Uri.encodeComponent(accountId)}|'; + return _uncertainOversizedParking.any((key) => key.startsWith(prefix)) || + _uncertainDurableRecords.any((key) => key.startsWith(prefix)); + } + + bool get _hasDurabilityFailureForCurrentAccount => + _hasDurabilityFailureForAccount(state.accountId); + + /// How many times writing the outbox has failed, so a caller can tell + /// whether its own save failed rather than an earlier one. + int get persistenceFailureCount => _persistenceFailureCount; + int _persistenceFailureCount = 0; + + void _recordPersistenceFailure(Object error, StackTrace stackTrace) { + _persistenceFailureCount++; + log('Durable outbox persistence failed: $error', + name: 'strategy_outbox', error: error, stackTrace: stackTrace); + if (_isDisposed) return; + final queued = Map.from( + state.queuedByEntityKey, + ); + final inFlight = Map.from( + state.inFlightByEntityKey, + ); + final paused = Map.from( + state.pausedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + for (final record in _uncertainDurableIntents.values.where((record) => + record.accountId == state.accountId && + record.strategyPublicId == state.strategyPublicId)) { + attention[record.entityKey] = QueuedEntityIntent( + entityKey: record.entityKey, + pending: record.pending, + ); + queued.remove(record.entityKey); + inFlight.remove(record.entityKey); + paused.remove(record.entityKey); + } + state = state.copyWith( + queuedByEntityKey: queued, + inFlightByEntityKey: inFlight, + pausedByEntityKey: paused, + attentionByEntityKey: attention, + isFlushing: false, + lastError: 'Cloud work could not be saved to the durable outbox: $error', + hasDurabilityFailure: true, + ); + } + + void _scheduleFlush({required bool flushImmediately}) { + if (_isDisposed) return; + if (flushImmediately) { + unawaited(flushNow()); + return; + } + _debounceTimer?.cancel(); + _debounceTimer = Timer(_debounceDelay, () => unawaited(flushNow())); + } + + void retryCurrentAccount() { + if (_isDisposed) return; + _scheduleBackgroundDrain(ignoreBackoff: true); + if (state.queuedByEntityKey.isNotEmpty) { + unawaited(flushNow()); + } + } + + void _scheduleBackgroundDrain({bool ignoreBackoff = false}) { + if (_isDisposed) return; + _backgroundRetryTimer?.cancel(); + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op)) + .toList(growable: false); + if (candidates.isEmpty) return; + final nextAttempt = candidates + .map(_nextAttemptAt) + .reduce((left, right) => left.isBefore(right) ? left : right); + final delay = ignoreBackoff || !nextAttempt.isAfter(now) + ? Duration.zero + : nextAttempt.difference(now); + _backgroundRetryTimer = Timer( + delay, + () => unawaited(_drainNextBackgroundStrategy( + ignoreBackoff: ignoreBackoff, + )), + ); + } + + Future _drainNextBackgroundStrategy({ + required bool ignoreBackoff, + }) async { + if (_isDisposed) return; + if (_networkBusy) { + _backgroundRetryTimer = Timer( + _busyBackgroundRetryDelay, + () => unawaited(_drainNextBackgroundStrategy( + ignoreBackoff: ignoreBackoff, + )), + ); + return; + } + await _writeTail; + if (_isDisposed) return; + final accountId = state.accountId; + if (accountId == null) return; + final activeStrategyId = state.strategyPublicId; + final now = DateTime.now(); + final candidates = _recordsByStorageKey.values + .where((record) => + record.accountId == accountId && + record.strategyPublicId != activeStrategyId && + (record.status == DurableOutboxStatus.queued || + record.status == DurableOutboxStatus.inFlight) && + !_uncertainDurableRecords.contains(record.storageKey) && + !cloudOperationExceedsPolicy(record.pending.op) && + (ignoreBackoff || !_nextAttemptAt(record).isAfter(now))) + .toList(growable: false) + ..sort((left, right) => left.updatedAt.compareTo(right.updatedAt)); + if (candidates.isEmpty) { + _scheduleBackgroundDrain(); + return; + } + await _flushStrategy( + accountId: accountId, + strategyPublicId: candidates.first.strategyPublicId, + isBackground: true, + ignoreBackoff: ignoreBackoff, + ); + } + + DateTime _nextAttemptAt(DurableOutboxRecord record) { + final lastAttemptAt = record.pending.lastAttemptAt; + if (lastAttemptAt == null || + record.status == DurableOutboxStatus.inFlight) { + return DateTime.fromMillisecondsSinceEpoch(0); + } + final exponent = record.pending.attempts.clamp(0, 6); + return lastAttemptAt.add( + Duration(milliseconds: 300 * (1 << exponent)), + ); + } + + void _scheduleRetry(List pending, {Duration? delay}) { + if (_isDisposed || pending.isEmpty) return; + final maxAttempt = pending.fold( + 0, + (value, item) => math.max(value, item.attempts), + ); + final retryDelay = + delay ?? Duration(milliseconds: 300 * (1 << maxAttempt.clamp(0, 6))); + _retryTimer?.cancel(); + _retryTimer = Timer(retryDelay, () => unawaited(flushNow())); + } + + Duration _offlineRetryDelay() { + final exponent = _offlineRetryCount.clamp(0, 6); + _offlineRetryCount += 1; + return Duration(milliseconds: 300 * (1 << exponent)); + } + + bool _sameIntent(StrategyOp left, StrategyOp right) { + return left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + cloudJsonEquivalent(left.payload, right.payload) && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision; + } + + StrategyOp? _mergeQueuedIntent(StrategyOp existing, StrategyOp desired) { + if ((existing is PageAddOp && desired is PageDeleteOp) || + (existing is ElementAddOp && desired is ElementDeleteOp) || + (existing is LineupAddOp && desired is LineupDeleteOp)) { + return null; + } + final replacementOpId = const Uuid().v4(); + if (existing case PageAddOp()) { + if (desired case PagePatchOp()) { + return PageAddOp( + opId: replacementOpId, + pagePublicId: existing.pagePublicId, + payload: {...existing.payload, ...desired.payload}, + sortIndex: existing.sortIndex, + expectedStrategyRevision: existing.expectedStrategyRevision, + ); + } + } + if (existing case ElementAddOp()) { + if (desired case ElementPatchOp()) { + return ElementAddOp( + opId: replacementOpId, + elementPublicId: existing.elementPublicId, + pagePublicId: desired.pagePublicId ?? existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedElementRevision: existing.expectedElementRevision, + ); + } + } + if (existing case LineupAddOp()) { + if (desired case LineupPatchOp()) { + return LineupAddOp( + opId: replacementOpId, + lineupPublicId: existing.lineupPublicId, + pagePublicId: desired.pagePublicId ?? existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedLineupRevision: existing.expectedLineupRevision, + ); + } + } + if (existing case StrategyPatchOp()) { + if (desired case StrategyPatchOp()) { + return StrategyPatchOp( + opId: replacementOpId, + payload: {...existing.payload, ...desired.payload}, + expectedStrategyRevision: desired.expectedStrategyRevision, + ); + } + } + if (existing case PagePatchOp()) { + if (desired case PagePatchOp()) { + return PagePatchOp( + opId: replacementOpId, + pagePublicId: desired.pagePublicId, + payload: {...existing.payload, ...desired.payload}, + expectedPageRevision: desired.expectedPageRevision, + ); + } + } + if (existing case ElementPatchOp()) { + if (desired case ElementPatchOp()) { + return ElementPatchOp( + opId: replacementOpId, + elementPublicId: desired.elementPublicId, + pagePublicId: desired.pagePublicId ?? existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedElementRevision: desired.expectedElementRevision, + ); + } + } + if (existing case LineupPatchOp()) { + if (desired case LineupPatchOp()) { + return LineupPatchOp( + opId: replacementOpId, + lineupPublicId: desired.lineupPublicId, + pagePublicId: desired.pagePublicId ?? existing.pagePublicId, + payload: desired.payload ?? existing.payload, + sortIndex: desired.sortIndex ?? existing.sortIndex, + expectedLineupRevision: desired.expectedLineupRevision, + ); + } + } + return desired.withOpId(replacementOpId); + } + + /// [resent] carries [original]'s work under a new op ID: the canvas's + /// work stays the canvas's. + void _keepCanvasWritten(StrategyOp original, StrategyOp resent) { + if (_canvasOpIds.contains(original.opId)) _canvasOpIds.add(resent.opId); + } + + /// Forgets the IDs of canvas work that landed or was dropped. Runs only + /// inside a serialized write, so no op another write marked is still + /// waiting for its record to be stored. + void _pruneCanvasOpIds() { + _canvasOpIds.retainAll({ + for (final record in [ + ..._recordsByStorageKey.values, + ..._uncertainDurableIntents.values, + ]) ...[ + record.pending.op.opId, + if (record.successorPending case final successor?) successor.op.opId, + ], + }); + } + + /// [op] as an add bringing its element or lineup back over the tombstone + /// at [revision], with the payload and place the user has. Null for a + /// change that does not hold them (a reorder, a delete); live sync's + /// patches always do. + static StrategyOp? _restoreOp(StrategyOp op, int revision) { + final opId = const Uuid().v4(); + return switch (op) { + ElementAddOp( + :final elementPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) || + ElementPatchOp( + :final elementPublicId, + pagePublicId: final pagePublicId?, + payload: final payload?, + sortIndex: final sortIndex?, + ) => + ElementAddOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: revision, + ), + LineupAddOp( + :final lineupPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) || + LineupPatchOp( + :final lineupPublicId, + pagePublicId: final pagePublicId?, + payload: final payload?, + sortIndex: final sortIndex?, + ) => + LineupAddOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: revision, + ), + _ => null, + }; + } + + StrategyOp _rebaseRejectedOp( + StrategyOp op, + int revision, { + required bool preserveAdd, + }) { + final opId = const Uuid().v4(); + return switch (op) { + StrategyPatchOp(:final payload) => StrategyPatchOp( + opId: opId, + payload: payload, + expectedStrategyRevision: revision, + ), + PageAddOp(:final pagePublicId, :final payload, :final sortIndex) => + PageAddOp( + opId: opId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedStrategyRevision: revision, + ), + PagePatchOp(:final pagePublicId, :final payload) => PagePatchOp( + opId: opId, + pagePublicId: pagePublicId, + payload: payload, + expectedPageRevision: revision, + ), + PageDeleteOp(:final pagePublicId) => PageDeleteOp( + opId: opId, + pagePublicId: pagePublicId, + expectedStrategyRevision: revision, + ), + PageReorderOp(:final pagePublicId, :final sortIndex) => PageReorderOp( + opId: opId, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedStrategyRevision: revision, + ), + PageContentPatchOp(:final pagePublicId, :final settings) => + PageContentPatchOp( + opId: opId, + pagePublicId: pagePublicId, + settings: settings, + expectedPageContentRevision: revision, + ), + ElementAddOp( + :final elementPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) => + preserveAdd + ? ElementAddOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: revision, + ) + : ElementPatchOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: revision, + ), + ElementPatchOp( + :final elementPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) => + ElementPatchOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedElementRevision: revision, + ), + ElementDeleteOp(:final elementPublicId, :final pagePublicId) => + ElementDeleteOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + expectedElementRevision: revision, + ), + ElementReorderOp( + :final elementPublicId, + :final pagePublicId, + :final sortIndex, + ) => + ElementReorderOp( + opId: opId, + elementPublicId: elementPublicId, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedElementRevision: revision, + ), + LineupAddOp( + :final lineupPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) => + preserveAdd + ? LineupAddOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: revision, + ) + : LineupPatchOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: revision, + ), + LineupPatchOp( + :final lineupPublicId, + :final pagePublicId, + :final payload, + :final sortIndex, + ) => + LineupPatchOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + payload: payload, + sortIndex: sortIndex, + expectedLineupRevision: revision, + ), + LineupDeleteOp(:final lineupPublicId, :final pagePublicId) => + LineupDeleteOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + expectedLineupRevision: revision, + ), + LineupReorderOp( + :final lineupPublicId, + :final pagePublicId, + :final sortIndex, + ) => + LineupReorderOp( + opId: opId, + lineupPublicId: lineupPublicId, + pagePublicId: pagePublicId, + sortIndex: sortIndex, + expectedLineupRevision: revision, + ), + }; + } + + /// The queue's error for [attention] and [paused] work in a strategy: + /// the active one unless [accountId] and [strategyPublicId] name the one + /// being opened, whose state is not built yet. + String? _loadedAttentionMessage({ + required List loadIssues, + required Map paused, + required Map attention, + String? accountId, + String? strategyPublicId, + }) { + accountId ??= state.accountId; + strategyPublicId ??= state.strategyPublicId; + DurableOutboxRecord? recordFor(EntitySyncKey key) => + accountId == null || strategyPublicId == null + ? null + : _recordsByStorageKey[DurableOutboxRecord.createStorageKey( + accountId: accountId, + strategyPublicId: strategyPublicId, + entityKey: key, + )]; + if (loadIssues.isNotEmpty) { + return 'The cloud outbox contains unreadable saved work.'; + } + if (_hasUncertainParkingForActiveStrategy) { + return _uncertainOversizedParkingMessage ?? + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.'; + } + if (_hasUncertainDurableRecordForActiveStrategy) { + return 'Cloud work could not be verified in the durable outbox.'; + } + if (attention.isNotEmpty) { + final hasOversizedWork = attention.entries.any((entry) { + if (cloudOperationExceedsPolicy(entry.value.pending.op)) return true; + final record = recordFor(entry.key); + return record?.pending.op.opId == entry.value.pending.op.opId && + record?.lastError == cloudOperationTooLargeMessage; + }); + if (hasOversizedWork) return cloudOperationTooLargeMessage; + // A change the server refused for its own reason (a lineup, a page + // in the trash) keeps that reason, so the sync button does not call + // it a conflict. Other attention beside it is noted so it is not left + // unexplained. + String? specificReason; + var hasOtherWork = false; + for (final entry in attention.entries) { + final record = recordFor(entry.key); + final lastError = record?.pending.op.opId == entry.value.pending.op.opId + ? record?.lastError + : null; + final reason = lastError == OpRejectionReason.deleted.wireName + ? teammateDeletedMessage + : lastError; + if (reason == lineupPageMismatchMessage || + reason == lineupOverlapMessage || + reason == teammateDeletedMessage || + reason == retiredLineupOpMessage || + reason == pageDeletedMessage) { + // A second, different reason is other work too: one explanation + // must not stand for both. + if (specificReason != null && reason != specificReason) { + hasOtherWork = true; + } + specificReason ??= reason; + } else { + hasOtherWork = true; + } + } + if (specificReason != null) { + return hasOtherWork + ? '$specificReason. $otherWorkNeedsAttentionNote' + : specificReason; + } + return 'Some saved work needs attention.'; + } + if (paused.isNotEmpty) return 'Some saved work is paused after retries.'; + return null; + } +} diff --git a/lib/providers/collab/strategy_presence_provider.dart b/lib/providers/collab/strategy_presence_provider.dart new file mode 100644 index 00000000..01679df0 --- /dev/null +++ b/lib/providers/collab/strategy_presence_provider.dart @@ -0,0 +1,68 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/collab/presence/presence_room.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +export 'package:icarus/collab/presence/presence_models.dart'; + +/// Who else has the open cloud strategy open, and their cursors. Joins the +/// strategy's room while the editor shows a cloud strategy, and leaves as soon +/// as nothing on screen watches it, however the editor was left. Local +/// strategies have no room. +final strategyPresenceProvider = + NotifierProvider.autoDispose( + StrategyPresenceNotifier.new, +); + +class StrategyPresenceNotifier extends AutoDisposeNotifier { + PresenceRoom? _room; + + @override + PresenceRoomState build() { + final strategyPublicId = ref.watch( + strategyProvider.select( + (s) => + s.isOpen && s.source == StrategySource.cloud ? s.strategyId : null, + ), + ); + final cloudEnabled = ref.watch(isCloudCollabEnabledProvider); + if (strategyPublicId == null || !cloudEnabled) { + _room = null; + return PresenceRoomState.off; + } + + final repository = ref.read(convexStrategyRepositoryProvider); + final room = PresenceRoom( + issuePass: () => repository.issueRoomPass(strategyPublicId), + ); + _room = room; + final subscription = room.states.listen((next) => state = next); + ref.onDispose(() { + subscription.cancel(); + room.dispose(); + }); + room.start(); + // What this user is editing goes out as it changes, and from the start. + ref.listen( + myLineupEditingProvider, + (_, editing) => room.setEditing(editing), + fireImmediately: true, + ); + return room.state; + } + + /// The pointer is over the map at ([x], [y]) in attack-side world + /// coordinates, on the page being viewed. + void moveCursor(double x, double y) { + final pageId = ref.read(strategyPageSessionProvider).activePageId; + if (pageId == null) return; + _room?.moveCursor(PresenceCursor(pageId: pageId, x: x, y: y)); + } + + void hideCursor() => _room?.hideCursor(); +} diff --git a/lib/providers/collab/trashed_pages_provider.dart b/lib/providers/collab/trashed_pages_provider.dart new file mode 100644 index 00000000..10d05098 --- /dev/null +++ b/lib/providers/collab/trashed_pages_provider.dart @@ -0,0 +1,49 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; + +/// How long after a failed read the trash is read again. +const trashRecheckDelay = Duration(seconds: 15); + +/// Whether the open cloud strategy has pages in its trash, so Recently +/// deleted is worth offering. Read again whenever the strategy's revision +/// moves: deleting and restoring a page both move it, element edits do not. +/// A read that fails (offline, say) counts as none and is tried again after +/// [trashRecheckDelay], so the button comes back once the connection does. +/// It is also read again when the first page's 30 days run out, so the button +/// goes with the last restorable page. +final hasTrashedPagesProvider = FutureProvider.autoDispose((ref) async { + final (strategyId, _) = ref.watch( + remoteEditorSnapshotProvider.select( + (snapshot) => ( + snapshot.valueOrNull?.header.publicId, + snapshot.valueOrNull?.header.revision, + ), + ), + ); + if (strategyId == null) return false; + try { + final pages = await ref + .read(convexStrategyRepositoryProvider) + .listTrashedPages(strategyId); + if (pages.isEmpty) return false; + final firstExpiry = pages + .map((page) => page.restorableUntil) + .reduce((a, b) => a.isBefore(b) ? a : b); + final untilExpiry = firstExpiry.difference(DateTime.now()); + // A deadline already past here may not be on the server's clock yet: + // wait the usual delay rather than read again at once, over and over. + final expire = Timer( + untilExpiry.isNegative ? trashRecheckDelay : untilExpiry, + ref.invalidateSelf, + ); + ref.onDispose(expire.cancel); + return true; + } catch (_) { + final recheck = Timer(trashRecheckDelay, ref.invalidateSelf); + ref.onDispose(recheck.cancel); + return false; + } +}); diff --git a/lib/providers/drawing_provider.dart b/lib/providers/drawing_provider.dart index 73523e0b..b734e7a8 100644 --- a/lib/providers/drawing_provider.dart +++ b/lib/providers/drawing_provider.dart @@ -2,8 +2,10 @@ import 'dart:convert'; import 'dart:developer' as dev; import 'dart:math'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/bounding_box.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/drawing_element.dart'; @@ -11,6 +13,7 @@ import 'package:icarus/const/json_converters.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:uuid/uuid.dart'; class DrawingState { @@ -63,16 +66,6 @@ class DrawingState { final drawingProvider = NotifierProvider(DrawingProvider.new); -class DrawingProviderSnapshot { - final DrawingState state; - final List poppedElements; - - const DrawingProviderSnapshot({ - required this.state, - required this.poppedElements, - }); -} - class DrawingProvider extends Notifier { List poppedElements = []; @@ -82,48 +75,72 @@ class DrawingProvider extends Notifier { } void undoAction(UserAction action) { - final newElements = [...state.elements]; - try { + final delta = action.objectDelta; + if (delta == null) { + final newElements = [...state.elements]; switch (action.type) { case ActionType.addition: - final index = DrawingElement.getIndexByID(action.id, newElements); - poppedElements.add(newElements.removeAt(index)); - + _removeDrawingById(action.id); + return; case ActionType.deletion: - final index = DrawingElement.getIndexByID(action.id, poppedElements); - newElements.add(poppedElements.removeAt(index)); + if (poppedElements.isEmpty) return; + newElements.add(cloneDrawingElement(poppedElements.removeLast())); + state = state.copyWith(elements: newElements); + _triggerRepaint(); + return; case ActionType.edit: case ActionType.bulkDeletion: case ActionType.transaction: - break; + return; } - } catch (_) { - dev.log("Can't find index in undo action"); } - state = state.copyWith(elements: newElements); - _triggerRepaint(); + switch (action.type) { + case ActionType.addition: + _removeDrawingById(action.id); + return; + case ActionType.deletion: + final before = delta.before?.drawing; + if (before == null) return; + _upsertDrawing(cloneDrawingElement(before)); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void redoAction(UserAction action) { - final newElements = [...state.elements]; - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = DrawingElement.getIndexByID(action.id, poppedElements); - newElements.add(poppedElements.removeAt(index)); + if (poppedElements.isEmpty) return; + _upsertDrawing(cloneDrawingElement(poppedElements.removeLast())); + return; case ActionType.deletion: - final index = DrawingElement.getIndexByID(action.id, newElements); - poppedElements.add(newElements.removeAt(index)); + _removeDrawingById(action.id); + return; case ActionType.edit: case ActionType.bulkDeletion: case ActionType.transaction: - break; + return; } - } catch (_) { - dev.log("Can't find index in redo action"); } - state = state.copyWith(elements: newElements); - _triggerRepaint(); + switch (action.type) { + case ActionType.addition: + final after = delta.after?.drawing; + if (after == null) return; + _upsertDrawing(cloneDrawingElement(after)); + return; + case ActionType.deletion: + _removeDrawingById(action.id); + return; + case ActionType.edit: + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } String toJson() { @@ -363,12 +380,16 @@ class DrawingProvider extends Notifier { final newElements = [...state.elements]; final poppedElement = newElements.removeAt(index); - poppedElements.add(poppedElement); + poppedElements.removeWhere((element) => element.id == poppedElement.id); + poppedElements.add(cloneDrawingElement(poppedElement)); final action = UserAction( type: ActionType.deletion, id: poppedElement.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.drawing(poppedElement), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -631,9 +652,13 @@ class DrawingProvider extends Notifier { ); final action = UserAction( - type: ActionType.addition, - id: finalDrawing.id, - group: ActionGroup.drawing); + type: ActionType.addition, + id: finalDrawing.id, + group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(finalDrawing), + ), + ); ref.read(actionProvider.notifier).addAction(action); _triggerRepaint(); @@ -711,6 +736,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: rectangle.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(rectangle), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -791,6 +819,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: ellipse.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(ellipse), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -868,6 +899,9 @@ class DrawingProvider extends Notifier { type: ActionType.addition, id: line.id, group: ActionGroup.drawing, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.drawing(line), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -887,30 +921,67 @@ class DrawingProvider extends Notifier { _triggerRepaint(); } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + /// A drawing still being drawn is not on the page yet and is left alone. + void mergeRemote( + List incoming, bool Function(String id) keep) { + final coordinateSystem = CoordinateSystem.instance; + final merged = mergeRemoteItems( + current: state.elements, + incoming: incoming, + idOf: (drawing) => drawing.id, + keep: keep, + ); + final onScreen = { + for (final drawing in state.elements) + if (drawing is FreeDrawing) drawing.id: drawing, + }; + for (final drawing in incoming) { + if (drawing is! FreeDrawing || keep(drawing.id)) continue; + // A stroke whose points did not change reuses the path already built + // for this canvas; on a busy page most strokes are unchanged. + final current = onScreen[drawing.id]; + if (current != null && + current.thickness == drawing.thickness && + listEquals(current.listOfPoints, drawing.listOfPoints)) { + drawing.updatePath(current.path); + } else { + drawing.rebuildPath(coordinateSystem); + } + } + state = state.copyWith(elements: merged); + _triggerRepaint(); + } + void clearAll() { poppedElements = []; state = DrawingState(elements: []); _triggerRepaint(); } - DrawingProviderSnapshot takeSnapshot() { - return DrawingProviderSnapshot( - state: DrawingState( - elements: [...state.elements], - updateCounter: state.updateCounter, - currentElement: state.currentElement, - ), - poppedElements: [...poppedElements], - ); + void _upsertDrawing(DrawingElement element) { + final newElements = [...state.elements]; + final index = DrawingElement.getIndexByID(element.id, newElements); + if (index < 0) { + newElements.add(element); + } else { + newElements[index] = element; + } + state = state.copyWith(elements: newElements); + _triggerRepaint(); } - void restoreSnapshot(DrawingProviderSnapshot snapshot) { - poppedElements = [...snapshot.poppedElements]; - state = DrawingState( - elements: [...snapshot.state.elements], - updateCounter: snapshot.state.updateCounter, - currentElement: snapshot.state.currentElement, - ); + void _removeDrawingById(String id) { + final newElements = [...state.elements]; + final index = DrawingElement.getIndexByID(id, newElements); + if (index < 0) { + return; + } + final removedElement = newElements.removeAt(index); + poppedElements.removeWhere((element) => element.id == id); + poppedElements.add(cloneDrawingElement(removedElement)); + state = state.copyWith(elements: newElements); _triggerRepaint(); } } diff --git a/lib/providers/editor_operation_provider.dart b/lib/providers/editor_operation_provider.dart new file mode 100644 index 00000000..df86fd3e --- /dev/null +++ b/lib/providers/editor_operation_provider.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; + +/// What a pressed pointer landed on. +class EditorPointerHold { + const EditorPointerHold({this.onCanvas = false, this.entityIds = const {}}); + + /// Pressed inside the strategy canvas. Presses elsewhere (sidebar, dialogs, + /// overlays) may drive an edit whose target we cannot see. + final bool onCanvas; + + /// The canvas items under the pointer. + final Set entityIds; + + EditorPointerHold copyWith({bool? onCanvas, Set? entityIds}) => + EditorPointerHold( + onCanvas: onCanvas ?? this.onCanvas, + entityIds: entityIds ?? this.entityIds, + ); +} + +final editorPointersProvider = + NotifierProvider>( + EditorPointersNotifier.new); + +/// Pointer-down reaches the deepest listener first: an item's marker, then the +/// canvas, then the app-wide scope. Each adds what it knows to the same hold. +class EditorPointersNotifier extends Notifier> { + bool _disposed = false; + + @override + Map build() { + _disposed = false; + ref.onDispose(() => _disposed = true); + return const {}; + } + + void holdEntity(int pointer, String id) { + final hold = state[pointer] ?? const EditorPointerHold(); + state = { + ...state, + pointer: + hold.copyWith(onCanvas: true, entityIds: {...hold.entityIds, id}), + }; + } + + /// Every press holding [heldId] now holds [id] as well. + void holdAlongside(String heldId, String id) { + state = { + for (final MapEntry(key: pointer, value: hold) in state.entries) + pointer: hold.entityIds.contains(heldId) + ? hold.copyWith(entityIds: {...hold.entityIds, id}) + : hold, + }; + } + + void markCanvas(int pointer) { + final hold = state[pointer] ?? const EditorPointerHold(); + state = {...state, pointer: hold.copyWith(onCanvas: true)}; + } + + void down(int pointer) { + if (state.containsKey(pointer)) return; + state = {...state, pointer: const EditorPointerHold()}; + } + + void release(int pointer) { + // Pointer routing finishes before drag-end/cancel callbacks commit their + // edits. Keep the hold until all those callbacks have run. + scheduleMicrotask(() { + if (!_disposed && state.containsKey(pointer)) { + state = {...state}..remove(pointer); + } + }); + } +} + +/// The canvas items a remote update must leave as they are on screen, because +/// the user is in the middle of changing them. Null means a press we cannot +/// attribute is down, so no remote update may apply yet. +final editorHeldEntitiesProvider = Provider?>((ref) { + final holds = ref.watch(editorPointersProvider).values; + if (holds.any((hold) => !hold.onCanvas)) return null; + final placement = + ref.watch(lineUpProvider.select((state) => state.placement)); + final drafts = ref.watch(textDraftProvider.select((drafts) => drafts.keys)); + return { + for (final hold in holds) ...hold.entityIds, + ...drafts, + if (placement?.pinnedOriginId case final id?) id, + if (placement?.pinnedLandingId case final id?) id, + }; +}); + +/// Whether anything at all is mid-way: a press, a stroke, a lineup placement +/// or a text draft. Replacing the whole page (a different page than the one on +/// screen) waits for this; merging into the page on screen does not. +final editorBusyProvider = Provider((ref) { + final pointers = ref.watch(editorPointersProvider); + final drawing = ref.watch( + drawingProvider.select((state) => state.currentElement != null), + ); + final placement = ref.watch( + lineUpProvider.select((state) => state.placement != null), + ); + final text = + ref.watch(textDraftProvider.select((drafts) => drafts.isNotEmpty)); + return pointers.isNotEmpty || drawing || placement || text; +}); diff --git a/lib/providers/folder_provider.dart b/lib/providers/folder_provider.dart index c51f4889..eba87785 100644 --- a/lib/providers/folder_provider.dart +++ b/lib/providers/folder_provider.dart @@ -1,113 +1,116 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:uuid/uuid.dart'; -enum FolderColor { - generic, - red, - blue, - green, - orange, - purple, - custom, -} - -class Folder extends HiveObject { - String name; - final String id; - final DateTime dateCreated; - String? parentID; // null for root folders, clearer than empty string - int iconId; - FolderColor color; - Color? customColor; - - Folder({ - required this.name, - required this.id, - required this.dateCreated, - int? iconId, - IconData? icon, - this.color = FolderColor.red, - this.parentID, // Optional, defaults to null (root) - this.customColor, - }) : iconId = iconId ?? - (icon == null - ? FolderIconRegistry.defaultId - : FolderIconRegistry.idForLegacyIconData(icon)); - - static Map folderColorMap = { - FolderColor.red: Colors.red, - FolderColor.blue: Colors.blue, - FolderColor.green: Colors.green, - FolderColor.orange: Colors.orange, - FolderColor.purple: Colors.purple, - FolderColor.generic: Settings.tacticalVioletTheme.card, - }; - - static List folderColors = [ - FolderColor.red, - FolderColor.blue, - FolderColor.green, - FolderColor.orange, - FolderColor.purple, - FolderColor.generic, - ]; - - @Deprecated('Use iconId and FolderIconRegistry instead.') - IconData get icon => FolderIconRegistry.legacyIconDataForId(iconId); - - @Deprecated('Use iconId and FolderIconRegistry instead.') - set icon(IconData icon) { - iconId = FolderIconRegistry.idForLegacyIconData(icon); - } - - @Deprecated('Use FolderIconRegistry.pickerEntries instead.') - static List get folderIcons => [ - for (final entry in FolderIconRegistry.pickerEntries) - if (entry.iconData != null) entry.iconData!, - ]; - - bool get isRoot => parentID == null; -} +export 'package:icarus/domain/folder.dart' show Folder, FolderColor; final folderProvider = NotifierProvider(FolderProvider.new); class FolderProvider extends Notifier { + String? _localCurrentFolderId; + String? _cloudCurrentFolderId; + Future createFolder({ required String name, required int iconId, required FolderColor color, Color? customColor, String? parentID, + LibraryWorkspace? workspace, }) async { + final targetWorkspace = workspace ?? _currentWorkspace; final newFolder = Folder( iconId: iconId, name: name, id: const Uuid().v4(), dateCreated: DateTime.now(), - parentID: parentID ?? state, + parentID: parentID ?? _currentFolderIdForWorkspace(targetWorkspace), customColor: customColor, color: color, ); + if (targetWorkspace == LibraryWorkspace.cloud) { + final icon = FolderIconRegistry.resolve(newFolder.iconId).iconData; + try { + await ref.read(convexStrategyRepositoryProvider).createFolder( + publicId: newFolder.id, + name: name, + parentFolderPublicId: newFolder.parentID, + iconId: newFolder.iconId, + iconCodePoint: icon?.codePoint, + iconFontFamily: icon?.fontFamily, + iconFontPackage: icon?.fontPackage, + color: color.name, + customColorValue: customColor?.toARGB32(), + ); + ref.invalidate(cloudFolderTreeProvider); + return newFolder; + } catch (error, stackTrace) { + await _maybeReportCloudUnauthenticated( + source: 'folder:create', + error: error, + stackTrace: stackTrace, + ); + // Do NOT fall through to the local write: that silently created a + // local folder from the cloud view whenever the server call failed. + Settings.showToast( + message: "Couldn't create the cloud folder. Check your connection " + 'and try again.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + rethrow; + } + } + + if (!ref.read(platformPolicyProvider).allowsLocalLibrary) { + // Refuse rather than save into a library this platform keeps hidden. + throw StateError('Folders here are created in the cloud only.'); + } await Hive.box(HiveBoxNames.foldersBox) .put(newFolder.id, newFolder); return newFolder; } void updateID(String? id) { - state = id; + updateWorkspaceFolderId(_currentWorkspace, id); + } + + /// Enters [folderId], which lives in [store]. My Library shows folders from + /// both stores side by side, so opening one also makes its store active. + void openFolder({required String folderId, required LibraryWorkspace store}) { + ref.read(libraryWorkspaceProvider.notifier).select(store); + updateWorkspaceFolderId(store, folderId); } void clearID() { - state = null; + updateWorkspaceFolderId(_currentWorkspace, null); + } + + void updateWorkspaceFolderId(LibraryWorkspace workspace, String? id) { + _setFolderIdForWorkspace(workspace, id); + if (_currentWorkspace == workspace) { + state = id; + } + } + + String? currentFolderIdForWorkspace(LibraryWorkspace workspace) { + return _currentFolderIdForWorkspace(workspace); } List getFullPathIDs(Folder? folder) { @@ -117,7 +120,7 @@ class FolderProvider extends Notifier { while (currentFolder != null) { pathIDs.insert(0, currentFolder.id); if (currentFolder.parentID != null) { - currentFolder = findFolderByID(currentFolder.parentID!); + currentFolder = findLocalFolderByID(currentFolder.parentID!); } else { currentFolder = null; } @@ -134,11 +137,53 @@ class FolderProvider extends Notifier { } Folder? findFolderByID(String id) { + return _currentWorkspace == LibraryWorkspace.cloud + ? null + : findLocalFolderByID(id); + } + + Folder? findLocalFolderByID(String id) { return Hive.box(HiveBoxNames.foldersBox).get(id); } - Future deleteFolder(String folderID) async { - // state = state.where((folder) => folder.id != folderID).toList(); + Folder? findCloudFolderByID( + String id, + Iterable cloudFolders, + ) { + return cloudFolders + .where((entry) => entry.folder.id == id) + .map((entry) => entry.folder) + .firstOrNull; + } + + Future deleteFolder( + String folderID, { + LibraryWorkspace? workspace, + }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + final result = await _runCloudAction( + action: () async { + await ref + .read(convexStrategyRepositoryProvider) + .deleteFolder(folderID); + return true; + }, + source: 'folder:delete', + failureMessage: "Couldn't delete this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + await ref.read(pinnedItemsProvider.notifier).removePin(folderID); + if (_currentFolderIdForWorkspace(LibraryWorkspace.cloud) == folderID) { + updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + } + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); + ref.invalidate(cloudStrategiesProvider); + return result; + } + await ref.read(pinnedItemsProvider.notifier).removePin(folderID); final strategyList = @@ -152,37 +197,165 @@ class FolderProvider extends Notifier { } for (final id in idsToDelete) { - await ref.read(strategyProvider.notifier).deleteStrategy(id); + await ref.read(strategyProvider.notifier).deleteStrategy( + id, + source: StrategySource.local, + ); } await Hive.box(HiveBoxNames.foldersBox).delete(folderID); + return CloudLibraryActionResult.succeeded; } - void editFolder({ + Future editFolder({ required Folder folder, required String newName, required int newIconId, required FolderColor newColor, required Color? newCustomColor, + LibraryWorkspace? workspace, }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + final newIcon = FolderIconRegistry.resolve(newIconId).iconData; + final iconFontFamily = newIcon?.fontFamily; + final iconFontPackage = newIcon?.fontPackage; + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).updateFolder( + folderPublicId: folder.id, + name: newName, + iconId: newIconId, + iconCodePoint: newIcon?.codePoint, + iconFontFamily: iconFontFamily, + clearIconFontFamily: iconFontFamily == null, + iconFontPackage: iconFontPackage, + clearIconFontPackage: iconFontPackage == null, + color: newColor.name, + customColorValue: newCustomColor?.toARGB32(), + clearCustomColorValue: newCustomColor == null, + ); + return true; + }, + source: 'folder:update', + failureMessage: "Couldn't update this cloud folder. Try again.", + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); + return result; + } + folder.name = newName; folder.iconId = newIconId; folder.customColor = newCustomColor; folder.color = newColor; await folder.save(); + return CloudLibraryActionResult.succeeded; } - void moveToFolder({required String folderID, String? parentID}) async { - final folder = findFolderByID(folderID); + Future moveToFolder({ + required String folderID, + String? parentID, + LibraryWorkspace? workspace, + }) async { + final targetWorkspace = workspace ?? _currentWorkspace; + if (targetWorkspace == LibraryWorkspace.cloud) { + final result = await _runCloudAction( + action: () async { + await ref.read(convexStrategyRepositoryProvider).moveFolder( + folderPublicId: folderID, + parentFolderPublicId: parentID, + ); + return true; + }, + source: 'folder:move', + failureMessage: "Couldn't move this cloud folder. Try again.", + showFailureMessage: true, + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudFoldersProvider); + ref.invalidate(cloudFolderTreeProvider); + return result; + } + + final folder = findLocalFolderByID(folderID); if (folder != null) { folder.parentID = parentID; await folder.save(); } + return CloudLibraryActionResult.succeeded; + } + + Future _runCloudAction({ + required Future Function() action, + required String source, + required String failureMessage, + bool showFailureMessage = false, + }) { + return ref.read(cloudLibraryActionReporterProvider).run( + action: action, + source: source, + failureMessage: failureMessage, + showFailureMessage: showFailureMessage, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); + } + + LibraryWorkspace get _currentWorkspace => ref.read(libraryWorkspaceProvider); + + String? _currentFolderIdForWorkspace(LibraryWorkspace workspace) { + return switch (workspace) { + LibraryWorkspace.local => _localCurrentFolderId, + LibraryWorkspace.cloud => _cloudCurrentFolderId, + LibraryWorkspace.community => null, + }; + } + + void _setFolderIdForWorkspace(LibraryWorkspace workspace, String? id) { + if (workspace == LibraryWorkspace.local) { + _localCurrentFolderId = id; + return; + } + if (workspace == LibraryWorkspace.community) { + return; + } + _cloudCurrentFolderId = id; + } + + Future _maybeReportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return; + } + + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); } @override String? build() { - return null; + ref.listen(libraryWorkspaceProvider, (_, workspace) { + state = _currentFolderIdForWorkspace(workspace); + }); + return _currentFolderIdForWorkspace(ref.read(libraryWorkspaceProvider)); } } + +extension on Iterable { + Folder? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/providers/image_provider.dart b/lib/providers/image_provider.dart index b2f7daba..1048e924 100644 --- a/lib/providers/image_provider.dart +++ b/lib/providers/image_provider.dart @@ -1,17 +1,24 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:ui' as ui; +import 'dart:async' show Completer; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:image/image.dart' as img; -import 'dart:ui' as ui; -import 'dart:async' show Completer; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; @@ -19,16 +26,6 @@ import 'package:uuid/uuid.dart'; final placedImageProvider = NotifierProvider(PlacedImageProvider.new); -class PlacedImageProviderSnapshot { - final List images; - final List poppedImages; - - const PlacedImageProviderSnapshot({ - required this.images, - required this.poppedImages, - }); -} - class ImageState { ImageState({ required this.images, @@ -129,15 +126,39 @@ class PlacedImageProvider extends Notifier { Future addImage( {required Uint8List imageBytes, + required String? strategyId, + required StrategySource? strategySource, required String fileExtension, Offset? position, double? aspectRatio, int? tagColorValue}) async { final imageID = const Uuid().v4(); + AppErrorReporter.reportInfo( + 'Image add requested: image=$imageID strategy=$strategyId ' + 'source=${strategySource?.name ?? 'unknown'} extension=$fileExtension ' + 'bytes=${imageBytes.length}', + source: 'cloud_media.image_provider', + ); - await ref - .read(placedImageProvider.notifier) - .saveSecureImage(imageBytes, imageID, fileExtension); + try { + await saveSecureImage( + imageBytes, + imageID, + fileExtension, + strategyId: strategyId, + ); + } on MediaTooLargeException catch (error) { + Settings.showToast( + message: error.userMessage, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + AppErrorReporter.reportInfo( + 'Image saved locally: image=$imageID strategy=$strategyId ' + 'extension=$fileExtension bytes=${imageBytes.length}', + source: 'cloud_media.image_provider', + ); final effectiveAspectRatio = aspectRatio ?? await getImageAspectRatio(imageBytes); @@ -151,25 +172,63 @@ class PlacedImageProvider extends Notifier { tagColorValue: tagColorValue, ); + if (strategySource == StrategySource.cloud && strategyId != null) { + AppErrorReporter.reportInfo( + 'Image enqueueing cloud upload: image=${placedImage.id} ' + 'strategy=$strategyId extension=$fileExtension', + source: 'cloud_media.image_provider', + ); + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: strategyId, + imagePublicId: placedImage.id, + fileExtension: fileExtension, + width: null, + height: null, + ); + } + final action = UserAction( type: ActionType.addition, id: placedImage.id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.image(placedImage), + ), ); ref.read(actionProvider.notifier).addAction(action); state = state.copyWith(images: [...state.images, placedImage]); + AppErrorReporter.reportInfo( + 'Image visible in editor: image=$imageID strategy=$strategyId ' + 'source=${strategySource?.name ?? 'unknown'}', + source: 'cloud_media.image_provider', + ); + + if (strategySource == StrategySource.cloud && strategyId != null) { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .commitStagedMediaReferences( + strategyPublicId: strategyId, + assetPublicIds: [placedImage.id], + ); + } } void removeImageAsAction(String id) { - if (!state.images.any((image) => image.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state.images); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.image(state.images[index]), + ), ), ); removeImage(id); @@ -180,8 +239,9 @@ class PlacedImageProvider extends Notifier { final index = PlacedWidget.getIndexByID(id, newImages); if (index < 0) return; - final image = newImages.removeAt(index); - poppedImages.add(image); + final removedImage = newImages.removeAt(index); + poppedImages.removeWhere((item) => item.id == id); + poppedImages.add(clonePlacedImage(removedImage)); state = state.copyWith(images: newImages); } @@ -191,6 +251,7 @@ class PlacedImageProvider extends Notifier { final index = PlacedWidget.getIndexByID(id, newImages); if (index < 0) return; + final before = ActionObjectState.image(newImages[index]); newImages[index].updatePosition(position); final temp = newImages.removeAt(index); @@ -199,6 +260,10 @@ class PlacedImageProvider extends Notifier { type: ActionType.edit, id: id, group: ActionGroup.image, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.image(temp), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -215,56 +280,87 @@ class PlacedImageProvider extends Notifier { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeImage(action.id); + return; + case ActionType.deletion: + if (poppedImages.isEmpty) return; + _upsertImage(clonePlacedImage(poppedImages.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state.images); + if (index < 0) return; + final newImages = [...state.images]; + newImages[index].undoAction(); + state = state.copyWith(images: newImages); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeImage(action.id); + return; case ActionType.deletion: - if (poppedImages.isEmpty) { + final before = delta.before?.image; + if (before == null) { return; } - final newImages = [...state.images]; - newImages.add(poppedImages.removeLast()); - state = state.copyWith(images: newImages); + _upsertImage(clonePlacedImage(before)); + return; case ActionType.edit: - undoPosition(action.id); + _writeEdit(action.id, delta.undoOnto); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } } - void undoPosition(String id) { - final newImages = [...state.images]; - final index = PlacedWidget.getIndexByID(id, newImages); - - if (index < 0) return; - newImages[index].undoAction(); - - state = state.copyWith(images: newImages); - } - void redoAction(UserAction action) { - final newImages = [...state.images]; - - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedImages); - newImages.add(poppedImages.removeAt(index)); - + if (poppedImages.isEmpty) return; + _upsertImage(clonePlacedImage(poppedImages.removeLast())); + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedImages); - poppedImages.add(newImages.removeAt(index)); - + removeImage(action.id); + return; case ActionType.edit: - final index = PlacedWidget.getIndexByID(action.id, newImages); + final index = PlacedWidget.getIndexByID(action.id, state.images); + if (index < 0) return; + final newImages = [...state.images]; newImages[index].redoAction(); + state = state.copyWith(images: newImages); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = state.copyWith(images: newImages); + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.image; + if (after == null) return; + _upsertImage(clonePlacedImage(after)); + return; + case ActionType.deletion: + removeImage(action.id); + return; + case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } static Future getImageFolder(String strategyID) async { @@ -294,6 +390,41 @@ class PlacedImageProvider extends Notifier { return imagesDirectory; } + static String buildImageFilePath( + String imagesDirectoryPath, + String imageID, + String fileExtension, + ) { + return path.join(imagesDirectoryPath, '$imageID$fileExtension'); + } + + static Future getImageFile({ + required String strategyID, + required String imageID, + required String fileExtension, + }) async { + final imageFolder = await getImageFolder(strategyID); + return File(buildImageFilePath(imageFolder.path, imageID, fileExtension)); + } + + static Future writeImageBytes({ + required Uint8List imageBytes, + required String strategyID, + required String imageID, + required String fileExtension, + }) async { + if (kIsWeb) return; + final file = await getImageFile( + strategyID: strategyID, + imageID: imageID, + fileExtension: fileExtension, + ); + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + await file.writeAsBytes(imageBytes); + } + Future toJson(String strategyID) async { // Asynchronously convert each image using the custom serializer. final List> jsonList = @@ -353,40 +484,62 @@ class PlacedImageProvider extends Notifier { state = newState; } + /// Keeps a newly picked image on this device before anything references + /// it: as a file in the strategy's image folder, or where there are no + /// image files (web), as pending bytes until it has uploaded. + /// Throws [MediaTooLargeException], keeping nothing, when the image can + /// never upload. A local strategy never uploads, so it takes any size. Future saveSecureImage( - Uint8List imageBytes, - String imageID, - String fileExtenstion, - ) async { - final strategyID = ref.read(strategyProvider).id; - // Get the system's application support directory. - if (kIsWeb) return; - final directory = await getApplicationSupportDirectory(); - - // Create a custom directory inside the application support directory. - - final customDirectory = Directory(path.join(directory.path, strategyID)); - - if (!await customDirectory.exists()) { - await customDirectory.create(recursive: true); + Uint8List imageBytes, String imageID, String fileExtenstion, + {required String? strategyId}) async { + if (strategyId == null) return; + final uploads = ref.read(strategyProvider).source == StrategySource.cloud || + !ref.read(imageFilesOnDeviceProvider); + if (uploads && imageBytes.length > maxCloudImageBytes) { + throw MediaTooLargeException(imageBytes.length); } - - // Now create the full file path. - final filePath = path.join( - customDirectory.path, - 'images', - '$imageID$fileExtenstion', + if (!ref.read(imageFilesOnDeviceProvider)) { + await ref.read(pendingMediaBytesProvider.notifier).put( + _pendingKey(strategyId: strategyId, imageId: imageID), + imageBytes, + ); + return; + } + await writeImageBytes( + imageBytes: imageBytes, + strategyID: strategyId, + imageID: imageID, + fileExtension: fileExtenstion, ); + } - // Ensure the images subdirectory exists. - final imagesDir = Directory(path.join(customDirectory.path, 'images')); - if (!await imagesDir.exists()) { - await imagesDir.create(recursive: true); - } + /// Lets go of an image picked for a lineup that will not reference it: the + /// lineup dialog was dismissed, or the image removed before saving. Where + /// images are files, the unused file is left for [deleteUnusedImages]. + Future discardDraftImage({ + required String imageId, + required String? strategyId, + }) async { + if (strategyId == null || ref.read(imageFilesOnDeviceProvider)) return; + await ref + .read(pendingMediaBytesProvider.notifier) + .remove(_pendingKey(strategyId: strategyId, imageId: imageId)); + } - // Write the file. - final file = File(filePath); - await file.writeAsBytes(imageBytes); + /// Pending bytes belong to the signed-in account, like the upload job. + PendingMediaKey _pendingKey({ + required String strategyId, + required String imageId, + }) { + final accountId = ref.read(cloudMediaAccountIdProvider); + if (accountId == null || accountId.isEmpty) { + throw StateError('Cloud images cannot be kept without an account.'); + } + return ( + accountId: accountId, + strategyPublicId: strategyId, + assetPublicId: imageId, + ); } static List deepCopyWith(List images) { @@ -400,21 +553,44 @@ class PlacedImageProvider extends Notifier { ); } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + void mergeRemote(List incoming, bool Function(String id) keep) { + state = state.copyWith( + images: mergeRemoteItems( + current: state.images, + incoming: incoming.map(_migrateLoadedImage).toList(), + idOf: (image) => image.id, + keep: keep, + ), + ); + } + void clearAll() { poppedImages = []; state = state.copyWith(images: []); } - PlacedImageProviderSnapshot takeSnapshot() { - return PlacedImageProviderSnapshot( - images: [...state.images], - poppedImages: [...poppedImages], - ); + /// Writes an edit onto the image as it is now. An image that is gone + /// (a teammate deleted it) stays gone. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = PlacedWidget.getIndexByID(id, state.images); + if (index < 0) return; + _upsertImage(write(ActionObjectState.image(state.images[index])).image!); } - void restoreSnapshot(PlacedImageProviderSnapshot snapshot) { - poppedImages = [...snapshot.poppedImages]; - state = state.copyWith(images: [...snapshot.images]); + void _upsertImage(PlacedImage image) { + final newImages = [...state.images]; + final index = PlacedWidget.getIndexByID(image.id, newImages); + if (index < 0) { + newImages.add(image); + } else { + newImages[index] = image; + } + state = state.copyWith(images: newImages); } } @@ -451,9 +627,6 @@ class PlacedImageSerializer { // Add the image bytes into the JSON. json['imageBytes'] = serializedBytes; - // Optionally update the object's link. - image.updateLink(filePath); - return json; } @@ -499,9 +672,6 @@ class PlacedImageSerializer { // Write the image bytes to disk. await file.writeAsBytes(imageBytes); - // Update the link on the instance. - placedImage.updateLink(filePath); - return placedImage; } diff --git a/lib/providers/image_widget_size_provider.dart b/lib/providers/image_widget_size_provider.dart index cdf30892..b18763e2 100644 --- a/lib/providers/image_widget_size_provider.dart +++ b/lib/providers/image_widget_size_provider.dart @@ -9,24 +9,13 @@ final imageWidgetSizeProvider = class ImageWidgetSizeProvider extends Notifier> { @override - Map build() { - return {}; - } + Map build() => {}; void updateSize(String id, Offset size) { state = {...state, id: size}; } - Offset getSize(String id) { - return state[id] ?? Offset.zero; - } - - Map takeSnapshotForIds(Iterable ids) { - return { - for (final id in ids) - if (state.containsKey(id)) id: state[id]!, - }; - } + Offset getSize(String id) => state[id] ?? Offset.zero; void clearEntries(Iterable ids) { final newState = {...state}; @@ -36,13 +25,6 @@ class ImageWidgetSizeProvider extends Notifier> { state = newState; } - void restoreSnapshot(Map snapshot) { - state = { - ...state, - ...snapshot, - }; - } - void clearAll() { state = {}; } diff --git a/lib/providers/library_navigation_provider.dart b/lib/providers/library_navigation_provider.dart new file mode 100644 index 00000000..fca090df --- /dev/null +++ b/lib/providers/library_navigation_provider.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +final libraryNavigationProvider = Provider((ref) { + return LibraryNavigation(ref); +}); + +/// Moves between the title-strip tabs. Each call lands at the tab's root. +class LibraryNavigation { + LibraryNavigation(this._ref); + + final Ref _ref; + + /// Opens My Library. The cloud is the active store when it is reachable so + /// new strategies and folders land online; otherwise the local library is. + void showLibrary() { + final cloudAvailable = _ref.read(isCloudWorkspaceAvailableProvider); + _ref.read(libraryWorkspaceProvider.notifier).select( + cloudAvailable ? LibraryWorkspace.cloud : LibraryWorkspace.local, + ); + _ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.home); + final folders = _ref.read(folderProvider.notifier); + folders.updateWorkspaceFolderId(LibraryWorkspace.local, null); + folders.updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + } + + /// Opens Shared. Returns false, and changes nothing, when the cloud is not + /// reachable so the caller can ask the user to sign in. + bool showShared() { + if (!_ref.read(isCloudWorkspaceAvailableProvider)) { + return false; + } + _ref.read(libraryWorkspaceProvider.notifier).select(LibraryWorkspace.cloud); + _ref + .read(cloudLibrarySectionProvider.notifier) + .select(CloudLibrarySection.sharedWithMe); + _ref + .read(folderProvider.notifier) + .updateWorkspaceFolderId(LibraryWorkspace.cloud, null); + return true; + } + + void showCommunity() { + _ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.community); + _ref.read(folderProvider.notifier).updateID(null); + } +} diff --git a/lib/providers/library_workspace_provider.dart b/lib/providers/library_workspace_provider.dart new file mode 100644 index 00000000..68084748 --- /dev/null +++ b/lib/providers/library_workspace_provider.dart @@ -0,0 +1,114 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/providers/auth_provider.dart'; + +enum LibraryWorkspace { + local, + cloud, + community, +} + +enum CloudLibrarySection { + home, + sharedWithMe, +} + +final isCloudWorkspaceAvailableProvider = Provider((ref) { + final auth = ref.watch(authProvider); + return auth.isAuthenticated && auth.isConvexUserReady; +}); + +/// Whether cloud mode will be usable: true once the user and Convex are +/// ready, false if the session ends signed out or in an auth incident, null +/// while sign-in or cloud setup is still settling (as it is right after a web +/// page reload). +final cloudWorkspaceOutcomeProvider = Provider((ref) { + if (ref.watch(isCloudWorkspaceAvailableProvider)) return true; + final auth = ref.watch(authProvider); + if (auth.isLoading) return null; + return switch (auth.convexAuthStatus) { + ConvexAuthStatus.signedOut || ConvexAuthStatus.incident => false, + ConvexAuthStatus.configuring || ConvexAuthStatus.ready => null, + }; +}); + +/// True while My Library stays closed because this platform needs the cloud +/// library and it is not reachable yet (signed out, or still signing in). +final librarySignInRequiredProvider = Provider((ref) { + return ref.watch(platformPolicyProvider).requiresSignIn && + !ref.watch(isCloudWorkspaceAvailableProvider); +}); + +final libraryWorkspaceProvider = + NotifierProvider( + LibraryWorkspaceNotifier.new, +); + +final isCloudWorkspaceSelectedProvider = Provider((ref) { + return ref.watch(libraryWorkspaceProvider) == LibraryWorkspace.cloud; +}); + +final cloudLibrarySectionProvider = + NotifierProvider( + CloudLibrarySectionNotifier.new, +); + +class LibraryWorkspaceNotifier extends Notifier { + @override + LibraryWorkspace build() { + ref.listen(isCloudWorkspaceAvailableProvider, (_, isAvailable) { + if (!isAvailable && state == LibraryWorkspace.cloud) { + state = LibraryWorkspace.local; + } + }); + return LibraryWorkspace.local; + } + + void select(LibraryWorkspace workspace) { + if (workspace == LibraryWorkspace.cloud && + !ref.read(isCloudWorkspaceAvailableProvider)) { + state = LibraryWorkspace.local; + return; + } + state = workspace; + } +} + +class CloudLibrarySectionNotifier extends Notifier { + @override + CloudLibrarySection build() { + ref.listen(libraryWorkspaceProvider, (_, workspace) { + if (workspace != LibraryWorkspace.cloud) { + state = CloudLibrarySection.home; + } + }); + return CloudLibrarySection.home; + } + + void select(CloudLibrarySection section) { + state = section; + } +} + +/// The three destinations in the library's title strip. `library` is the +/// user's own work from every store; `shared` is what teammates gave them; +/// `community` is the public space. The active store ([libraryWorkspaceProvider]) +/// stays an implementation detail behind the first tab. +enum LibraryTab { + library, + shared, + community, +} + +final libraryTabProvider = Provider((ref) { + final workspace = ref.watch(libraryWorkspaceProvider); + if (workspace == LibraryWorkspace.community) { + return LibraryTab.community; + } + final section = ref.watch(cloudLibrarySectionProvider); + if (workspace == LibraryWorkspace.cloud && + section == CloudLibrarySection.sharedWithMe) { + return LibraryTab.shared; + } + return LibraryTab.library; +}); diff --git a/lib/providers/map_provider.dart b/lib/providers/map_provider.dart index 8b31bea1..0b12b7cb 100644 --- a/lib/providers/map_provider.dart +++ b/lib/providers/map_provider.dart @@ -51,7 +51,9 @@ class MapProvider extends Notifier { ); } - void updateMap(MapValue map) => state = state.copyWith(currentMap: map); + void updateMap(MapValue map) { + state = state.copyWith(currentMap: map); + } void fromHive(MapValue map, bool isAttack) { state = state.copyWith(currentMap: map, isAttack: isAttack); @@ -95,9 +97,8 @@ class MapProvider extends Notifier { static MapValue fromJson(String json) { final mapName = jsonDecode(json); - final mapValue = Maps.mapNames.entries - .firstWhere((entry) => entry.value == mapName) - .key; + final mapValue = + Maps.mapNames.entries.firstWhere((entry) => entry.value == mapName).key; return mapValue; } diff --git a/lib/providers/marker_sizes_sync.dart b/lib/providers/marker_sizes_sync.dart index 717316cb..a82c0cf0 100644 --- a/lib/providers/marker_sizes_sync.dart +++ b/lib/providers/marker_sizes_sync.dart @@ -1,4 +1,4 @@ -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; /// Whether [agentSize] / [abilitySize] differ across strategy pages, using diff --git a/lib/providers/share_link_provider.dart b/lib/providers/share_link_provider.dart new file mode 100644 index 00000000..bfa4e2f7 --- /dev/null +++ b/lib/providers/share_link_provider.dart @@ -0,0 +1,261 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/services/browser_url.dart'; +import 'package:icarus/share/current_share_origin.dart'; +import 'package:icarus/share/pending_share_code_store.dart'; +import 'package:icarus/share/share_link_format.dart'; + +/// Where a share code waits for sign-in. Overridden in tests. +final pendingShareCodeStoreProvider = Provider( + (ref) => createPendingShareCodeStore(), +); + +/// The page's own URL on web (where a share link arrives), null on native. +/// Overridden in tests. +final sharePageUrlProvider = Provider((ref) => kIsWeb ? Uri.base : null); + +/// Replaces the address bar URL without navigating. Overridden in tests. +final replaceBrowserUrlProvider = + Provider((ref) => replaceBrowserUrl); + +/// A cloud strategy a redeemed share link asks to open. The navigation layer +/// (MouseNavigation, which owns the unsaved-changes guard) opens it and +/// clears this back to null. +final sharedStrategyToOpenProvider = StateProvider((ref) => null); + +/// A strategy open through a share link nobody has redeemed on this device: +/// the reader is signed out, and the link is their only access. The editor +/// reads with [token] and shows the reader how to sign in. Cleared once the +/// link is redeemed. +typedef ShareLinkView = ({String strategyPublicId, String token}); + +final shareLinkViewProvider = StateProvider((ref) => null); + +final shareLinkControllerProvider = + NotifierProvider(ShareLinkController.new); + +class ShareLinkController extends Notifier { + Future handleIncomingUri(Uri uri, {required String source}) async { + final currentOrigin = currentShareOrigin(); + if (!isIcarusShareUri(uri, currentOrigin: currentOrigin)) { + return false; + } + + final token = extractIcarusShareCode( + uri.toString(), + currentOrigin: currentOrigin, + ); + if (token == null || token.isEmpty) { + Settings.showToast( + message: 'That share link is missing a share code.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return true; + } + + _hold(token); + await redeemPendingIfPossible(source: source); + return true; + } + + /// Set [showFailureToasts] to false when the caller (e.g. a dialog) + /// presents failures inline itself; success toasts always show. + Future redeemPendingIfPossible({ + String source = 'pending', + bool showFailureToasts = true, + }) async { + final token = state; + final generation = _generation; + if (token == null || token.isEmpty) { + return false; + } + + final auth = ref.read(authProvider); + if (!auth.isAuthenticated && !auth.isLoading) { + return _viewWithoutAccount(token, generation); + } + if (!auth.isAuthenticated || !auth.isConvexUserReady) { + // The code stays held in the pending store; the app redeems it once + // the cloud is ready: right after a page load a saved session is + // restored but its cloud setup is still running, and an auth incident + // has its own prompt. + return false; + } + + try { + final response = await ref + .read(convexStrategyRepositoryProvider) + .redeemShareLink(token); + _release(generation); + ref.read(shareLinkViewProvider.notifier).state = null; + + // The library lands where the target now lives: the owner's own + // library, or Shared for anyone the link was shared with. + ref + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + ref.read(cloudLibrarySectionProvider.notifier).select( + response.isOwner + ? CloudLibrarySection.home + : CloudLibrarySection.sharedWithMe, + ); + ref.read(folderProvider.notifier).updateWorkspaceFolderId( + LibraryWorkspace.cloud, + response.folderPublicId, + ); + + // Only a link that granted something is news; one for a strategy or + // folder the user could already open just takes them there. + if (!response.alreadyHadAccess) { + Settings.showToast( + message: response.targetType == 'folder' + ? 'Shared folder added to your library.' + : 'Shared strategy added to your library.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + final strategyPublicId = response.strategyPublicId; + if (response.targetType == 'strategy' && strategyPublicId != null) { + ref.read(sharedStrategyToOpenProvider.notifier).state = + strategyPublicId; + } + return true; + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: 'share_link:$source', + error: error, + stackTrace: stackTrace, + ); + return false; + } + // The user is told (here, or inline by the dialog), so the code is done + // with. Keeping it would replay the failure on every web page reload. + _release(generation); + if (showFailureToasts) { + Settings.showToast( + message: 'Failed to redeem share link.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + return false; + } + } + + /// Opens a strategy link for a reader with no account, read-only. The code + /// stays held, and the page keeps its /share/ URL, so a reload opens + /// it again and signing in redeems it into the reader's library. + Future _viewWithoutAccount(String token, int generation) async { + if (ref.read(shareLinkViewProvider)?.token == token) { + return true; // Already open; an auth update asked again. + } + + try { + final strategyPublicId = await ref + .read(convexStrategyRepositoryProvider) + .resolveSharedStrategy(token); + if (_superseded(token, generation)) return false; + if (strategyPublicId == null) { + // Browsing a folder needs the cloud library, which needs an account. + Settings.showToast( + message: 'Sign in to open this shared folder.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + return false; + } + ref.read(shareLinkViewProvider.notifier).state = + (strategyPublicId: strategyPublicId, token: token); + ref.read(sharedStrategyToOpenProvider.notifier).state = strategyPublicId; + return true; + } catch (error) { + if (_superseded(token, generation)) return false; + final revoked = isShareLinkRevokedError(error); + if (revoked || isTypedConvexNotFoundError(error)) { + _release(generation); + Settings.showToast( + message: revoked + ? 'This share link was disabled by its owner.' + : 'This share link does not exist.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return false; + } + // Most likely offline. The code stays held for a reload to retry. + Settings.showToast( + message: 'Could not open the shared strategy. Check your connection ' + 'and reload.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return false; + } + } + + /// Whether [token]'s attempt is stale: a newer code arrived, or sign-in + /// redeemed this one while the attempt was waiting on the server. + bool _superseded(String token, int generation) => + generation != _generation || state != token; + + /// Drops the link a signed-out reader was viewing through, once it stopped + /// working: there is nothing left to open or to redeem. + void forgetViewedLink() { + final view = ref.read(shareLinkViewProvider); + ref.read(shareLinkViewProvider.notifier).state = null; + if (view != null && state == view.token) { + _release(_generation); + } + } + + Future redeemToken(String token) async { + _hold(token); + return redeemPendingIfPossible(source: 'manual', showFailureToasts: false); + } + + /// Bumped each time a code becomes pending. A redeem attempt remembers the + /// generation it started with and only clears that one, so a slow attempt + /// finishing late cannot clear a newer code that arrived meanwhile. + int _generation = 0; + + void _hold(String token) { + _generation += 1; + state = token; + ref.read(pendingShareCodeStoreProvider).write(token); + } + + void _release(int generation) { + if (generation != _generation) { + return; + } + state = null; + ref.read(pendingShareCodeStoreProvider).clear(); + _dropSharePathFromPageUrl(); + } + + /// Once a code is done with, the page must not keep its /share/ URL, + /// or every reload would redeem it again (and, after the link is disabled, + /// report a failure to someone who still has access). The fragment stays: + /// Flutter's router keeps the open route there. + void _dropSharePathFromPageUrl() { + final page = ref.read(sharePageUrlProvider); + if (page == null || + !isIcarusShareUri(page, currentOrigin: currentShareOrigin())) { + return; + } + ref.read(replaceBrowserUrlProvider)(Uri( + scheme: page.scheme, + host: page.host, + port: page.hasPort ? page.port : null, + path: '/', + fragment: page.fragment.isEmpty ? null : page.fragment, + )); + } + + /// Starts with any code a previous page load left waiting: on web, the one + /// opened before a Discord sign-in navigated the tab away. + @override + String? build() => ref.read(pendingShareCodeStoreProvider).read(); +} diff --git a/lib/providers/strategy_filter_provider.dart b/lib/providers/strategy_filter_provider.dart index a210b1d4..d0fb23a5 100644 --- a/lib/providers/strategy_filter_provider.dart +++ b/lib/providers/strategy_filter_provider.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; enum SortBy { alphabetical, diff --git a/lib/providers/strategy_image_source.dart b/lib/providers/strategy_image_source.dart new file mode 100644 index 00000000..9326b74c --- /dev/null +++ b/lib/providers/strategy_image_source.dart @@ -0,0 +1,189 @@ +import 'package:flutter/foundation.dart' show Uint8List; +import 'package:flutter/painting.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/local_image_file.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +/// Where the bytes for one image in the open strategy (a placed image or a +/// lineup image) come from. +sealed class StrategyImageSource { + const StrategyImageSource(); + + /// What to paint, or null while there is nothing to paint. + ImageProvider? get imageProvider => switch (this) { + LocalImageFile(:final path) => localImageProvider(path), + RemoteImageUrl(:final url) => NetworkImage( + url, + // Paint through an element when the host does not let the + // browser fetch the bytes. + webHtmlElementStrategy: WebHtmlElementStrategy.fallback, + ), + ImageBytes(:final bytes) => MemoryImage(bytes), + ImageLoading() || ImageFailed() => null, + }; +} + +/// The image file is on this device. Never produced on web. +final class LocalImageFile extends StrategyImageSource { + const LocalImageFile(this.path); + final String path; +} + +/// The image is fetched from its cloud asset URL. +final class RemoteImageUrl extends StrategyImageSource { + const RemoteImageUrl(this.url); + final String url; +} + +/// Bytes in memory: an image this device is still uploading, painted until +/// the cloud URL arrives (only where images are not files, on web), or one +/// fetched ahead of an offscreen capture. +final class ImageBytes extends StrategyImageSource { + const ImageBytes(this.bytes); + final Uint8List bytes; +} + +/// A cloud image whose URL has not arrived yet: its upload is under way, or +/// the page listing its asset is still loading. +final class ImageLoading extends StrategyImageSource { + const ImageLoading(); +} + +/// Nothing to show: the cloud upload failed, the server has no asset for the +/// image and none is on its way, or a local strategy is missing the file. +final class ImageFailed extends StrategyImageSource { + const ImageFailed(); +} + +typedef StrategyImageKey = ({String id, String? fileExtension}); + +/// What each image paints in an offscreen capture, by image id, and null +/// everywhere else. +/// +/// A capture renders in its own provider container, which has no live cloud +/// page, upload queue, or pending bytes to resolve an image from. The +/// capture resolves every image it paints before it starts and overrides +/// this provider with the result. +final captureImageSourcesProvider = + Provider?>((ref) => null); + +/// Where the bytes for [image] come from, read from a widget's build. +/// +/// The file check runs on every build, so a file written or removed while +/// the image is on screen is seen on the next rebuild. Every media cache +/// change rebuilds the caller, so a finished download is seen at once. +StrategyImageSource watchStrategyImageSource( + WidgetRef ref, + StrategyImageKey image, +) => + _strategyImageSource(ref.watch, image); + +/// Where the bytes for [image] come from right now, read once, as the +/// editor would paint it. +StrategyImageSource readStrategyImageSource( + WidgetRef ref, + StrategyImageKey image, +) => + _strategyImageSource(ref.read, image); + +StrategyImageSource _strategyImageSource( + T Function(ProviderListenable provider) watch, + StrategyImageKey image, +) { + final captured = watch(captureImageSourcesProvider); + if (captured != null) return captured[image.id] ?? const ImageFailed(); + + final (storageDirectory, source, strategyId) = watch( + strategyProvider + .select((s) => (s.storageDirectory, s.source, s.strategyId)), + ); + final (assetsLoaded, remoteAsset) = watch( + remoteEditorSnapshotProvider.select((snapshot) { + final page = snapshot.valueOrNull?.activePage; + return (page != null, page?.assetsById[image.id]); + }), + ); + final isCloudStrategy = source == StrategySource.cloud; + // Only cloud images are ever queued for upload. Until the signed-in + // account is known, or while the outbox holds records it could not read, + // the queue cannot rule a queued upload out. + final uploadMayBeQueuedHere = isCloudStrategy && + (watch(cloudMediaAccountIdProvider) == null || + watch( + cloudMediaUploadQueueProvider.select( + (queue) => + !queue.outboxIsReliable || + queue.jobsForStrategy(strategyId).any( + (job) => job.assetPublicId == image.id && !job.isFailed, + ), + ), + )); + watch(cloudMediaCacheProvider); + // Bytes this browser is still uploading for the signed-in account. They + // only paint when neither the file check below nor the cloud URL has + // anything. Where images are files there are never pending bytes. + Uint8List? pendingBytes; + if (!watch(imageFilesOnDeviceProvider)) { + final accountId = watch(cloudMediaAccountIdProvider); + if (accountId != null && strategyId != null) { + final key = pendingMediaStorageKey(( + accountId: accountId, + strategyPublicId: strategyId, + assetPublicId: image.id, + )); + pendingBytes = + watch(pendingMediaBytesProvider.select((pending) => pending[key])); + } + } + + return resolveStrategyImageSource( + localFilePath: findLocalImageFile( + storageDirectory: storageDirectory, + imageId: image.id, + fileExtension: image.fileExtension, + ), + isCloudStrategy: isCloudStrategy, + assetsLoaded: assetsLoaded, + remoteAsset: remoteAsset, + uploadMayBeQueuedHere: uploadMayBeQueuedHere, + pendingBytes: pendingBytes, + ); +} + +/// A file on this device wins, then the cloud URL, then bytes still +/// uploading from this device. Without any, a cloud image is on its way +/// while its asset is pending, while this device may have its upload queued, +/// or while the page that lists its asset is still loading. [assetsLoaded] +/// is that page's live snapshot. The server gives every image its content +/// shows a pending asset until the upload lands, so an image the loaded page +/// has no asset for is not coming: it failed, or its upload never came and +/// was swept. +StrategyImageSource resolveStrategyImageSource({ + required String? localFilePath, + required bool isCloudStrategy, + required bool assetsLoaded, + required RemoteImageAsset? remoteAsset, + required bool uploadMayBeQueuedHere, + Uint8List? pendingBytes, +}) { + if (localFilePath != null) return LocalImageFile(localFilePath); + final url = remoteAsset?.url; + if (url != null && url.isNotEmpty) return RemoteImageUrl(url); + if (pendingBytes != null) return ImageBytes(pendingBytes); + if (!isCloudStrategy) return const ImageFailed(); + if (remoteAsset != null) { + return remoteAsset.uploadStatus == 'failed' + ? const ImageFailed() + : const ImageLoading(); + } + return !assetsLoaded || uploadMayBeQueuedHere + ? const ImageLoading() + : const ImageFailed(); +} diff --git a/lib/providers/strategy_page_session_provider.dart b/lib/providers/strategy_page_session_provider.dart new file mode 100644 index 00000000..14afcc7a --- /dev/null +++ b/lib/providers/strategy_page_session_provider.dart @@ -0,0 +1,1857 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math' show max; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/page_transition/agent_path.dart'; +import 'package:icarus/page_transition/navigation_geometry_map.dart'; +import 'package:icarus/page_transition/transition_planner.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/providers/navigation_geometry_provider.dart'; +import 'package:icarus/providers/view_cone_geometry_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_page_apply.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy/strategy_page_source.dart'; +import 'package:icarus/view_cone/vision_geometry.dart'; + +enum PageTransitionState { + idle, + animatingForward, + animatingBackward, +} + +enum PageSwitchDirection { next, previous } + +/// The page on screen, which a teammate deleted while it held work the server +/// never got. [name] is its name when it was last loaded. +typedef DeletedPage = ({String pageId, String name}); + +/// How Keep both ended (see StrategyPageSessionNotifier.keepBothForRejected). +enum KeepBothOutcome { + /// The cloud's versions stand, and the user's are beside them as copies. + kept, + + /// The cloud's versions could not be loaded. Nothing changed. + unchanged, + + /// The user's versions were added as copies, but something failed before + /// the conflicts were resolved (the cloud's versions stopped loading, a + /// copy could not be saved, or the waiting work changed), so they still + /// wait. + copiesOnly, +} + +/// How an attempt to restore a deleted page ended. +enum DeletedPageRestore { + /// The page is back on the server, and the work on it is saving as usual. + /// The deleted page on screen is back on screen too. + restored, + + /// The server can no longer restore the page: its time in the trash is + /// over. Work on it cannot be saved. + gone, + + /// The page could not be restored, or not loaded once it was. Nothing on + /// this device changed; trying again is safe. + failed, +} + +class StrategyPageSessionState { + const StrategyPageSessionState({ + required this.activePageId, + required this.availablePageIds, + required this.transitionState, + required this.isApplyingPage, + this.deletedPage, + }); + + final String? activePageId; + final List availablePageIds; + final PageTransitionState transitionState; + final bool isApplyingPage; + + /// Set until the user has read that their unsaved work on the deleted page + /// on screen cannot be saved. The canvas stays on it until then. + final DeletedPage? deletedPage; + + StrategyPageSessionState copyWith({ + String? activePageId, + bool clearActivePageId = false, + List? availablePageIds, + PageTransitionState? transitionState, + bool? isApplyingPage, + DeletedPage? deletedPage, + bool clearDeletedPage = false, + }) { + return StrategyPageSessionState( + activePageId: + clearActivePageId ? null : (activePageId ?? this.activePageId), + availablePageIds: availablePageIds ?? this.availablePageIds, + transitionState: transitionState ?? this.transitionState, + isApplyingPage: isApplyingPage ?? this.isApplyingPage, + deletedPage: clearDeletedPage ? null : (deletedPage ?? this.deletedPage), + ); + } +} + +class _RemotePageHydrationKey { + const _RemotePageHydrationKey({ + required this.strategyPublicId, + required this.pageId, + required this.fingerprint, + }); + + final String strategyPublicId; + final String pageId; + final String fingerprint; + + @override + bool operator ==(Object other) { + return other is _RemotePageHydrationKey && + strategyPublicId == other.strategyPublicId && + pageId == other.pageId && + fingerprint == other.fingerprint; + } + + @override + int get hashCode => Object.hash( + strategyPublicId, + pageId, + fingerprint, + ); +} + +final strategyPageSessionProvider = + NotifierProvider( + StrategyPageSessionNotifier.new, +); + +class StrategyPageSessionNotifier extends Notifier { + _RemotePageHydrationKey? _lastHydratedRemotePageKey; + RemoteEditorSnapshot? _lastAppliedRemoteSnapshot; + bool _pendingRemoteReapply = false; + bool _isResolvingConflicts = false; + bool _remoteReapplyInFlight = false; + + /// A remote change is waiting for the user to finish what it touches. + /// Retried when the editor's holds change, not before: they may last a + /// while, and retrying sooner would only hold the change back again. + bool _remoteChangeWaitsForEditor = false; + bool _disposed = false; + int _pageSessionGeneration = 0; + + /// Pages whose delete from this device the server applied, with the + /// strategy revision the delete made. See [_deletedHere]. + final Map _pagesDeletedHere = {}; + + /// The latest strategy revision at which each page was seen live. + final Map _pagesSeenLiveAt = {}; + + /// Waiting work Keep both already put a copy of on the canvas, by + /// [_waitingWork] entry: pressing it again finishes the job instead of + /// copying the same version twice. + /// The copy's lineup ids, by entry, so every press can check the copy + /// is saved before anything is discarded. + final Map<(EntitySyncKey, (String, String?)), List> _keptAsCopy = {}; + + @override + StrategyPageSessionState build() { + ref.onDispose(() => _disposed = true); + ref.listen>( + remoteEditorSnapshotProvider, + (previous, next) { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + !strategyState.isOpen) { + return; + } + + final snapshot = next.valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) { + return; + } + if (snapshot.header.publicId == strategyState.strategyId) { + for (final page in snapshot.pages) { + _pagesSeenLiveAt.update( + page.publicId, + (seen) => max(seen, snapshot.header.revision), + ifAbsent: () => snapshot.header.revision, + ); + } + _resendEditsForPagesBack(snapshot); + } + + final pageIds = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + final orderedIds = + pageIds.map((page) => page.publicId).toList(growable: false); + if (!listEquals(orderedIds, state.availablePageIds)) { + state = state.copyWith(availablePageIds: orderedIds); + } + + final targetPageId = _resolveHydrationTargetPage(snapshot); + if (targetPageId == null) { + return; + } + + final hydrationKey = + _buildRemotePageHydrationKey(snapshot, targetPageId); + if (hydrationKey == null) { + return; + } + + if (_lastHydratedRemotePageKey != hydrationKey) { + _requestRemoteRehydrate(targetPageId); + } + }, + ); + + ref.listen(strategySaveStateProvider, (_, __) { + _resumePendingRemoteReapplyIfPossible(); + }); + + void editorChanged() { + if (_remoteChangeWaitsForEditor) { + _remoteChangeWaitsForEditor = false; + _pendingRemoteReapply = true; + } + // Draft completion publishes its saved value and history in the same + // call stack. Let those writes finish before inspecting save state. + scheduleMicrotask(_resumePendingRemoteReapplyIfPossible); + } + + ref.listen(editorBusyProvider, (_, __) => editorChanged()); + ref.listen?>( + editorHeldEntitiesProvider, (_, __) => editorChanged()); + + ref.listen(strategyOpQueueProvider, (previous, next) { + final previousAckBatch = + previous?.lastAckBatch ?? const []; + if (next.lastAckBatch.isEmpty || + identical(previousAckBatch, next.lastAckBatch)) { + return; + } + _notePagesDeletedHere(next); + unawaited(_reconcileAcks(next.lastAcks, next.lastAckBatch)); + }); + + return const StrategyPageSessionState( + activePageId: null, + availablePageIds: [], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + } + + /// Edits the server refused because their page was deleted go out again + /// once the page is live: restored here, by a teammate, or while this + /// strategy was closed. A refusal refreshes the snapshot, which then no + /// longer lists the page, so this cannot loop. + void _resendEditsForPagesBack(RemoteEditorSnapshot snapshot) { + // A "Use cloud" in progress decides this work's fate. ("Keep mine" goes + // through the same serialized retry, which only takes what is still in + // attention, so the two cannot send it twice.) + if (_isResolvingConflicts) return; + final live = {for (final page in snapshot.pages) page.publicId}; + final waiting = ref + .read(strategyOpQueueProvider) + .attentionByEntityKey + .keys + .any((key) => live.contains(key.pageId)); + if (!waiting) return; + unawaited( + ref.read(strategyOpQueueProvider.notifier).retryRestoredPages(live), + ); + } + + String? get activePageId => state.activePageId; + + Future initializeForStrategy({ + required String strategyId, + required StrategySource source, + required bool selectFirstPageIfNeeded, + }) async { + _pageSessionGeneration++; + final pageSource = _resolvePageSource(strategyId, source); + final pageIds = await pageSource.listPageIds(); + final initialPageId = + pageIds.contains(state.activePageId) ? state.activePageId : null; + final selected = initialPageId ?? + (selectFirstPageIfNeeded && pageIds.isNotEmpty ? pageIds.first : null); + + state = state.copyWith( + availablePageIds: pageIds, + activePageId: selected, + clearActivePageId: selected == null, + transitionState: PageTransitionState.idle, + isApplyingPage: false, + clearDeletedPage: true, + ); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: selected, + ); + + if (selected != null) { + await _rehydrateActivePageFromSource(selected); + } + // Snapshots that arrived while the strategy was opening went unheard: + // catch up on pages that came back while it was closed. + if (source == StrategySource.cloud) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot != null && snapshot.header.publicId == strategyId) { + _resendEditsForPagesBack(snapshot); + } + } + } + + Future setActivePage(String pageId) async { + if (pageId == state.activePageId || _deletedPageHoldsWork()) { + return; + } + await _switchToPage(pageId, animated: false); + } + + Future setActivePageAnimated( + String pageId, { + required PageTransitionDirection direction, + Duration duration = kPageTransitionDuration, + }) async { + if (pageId == state.activePageId || _deletedPageHoldsWork()) { + return; + } + final previousPageId = state.activePageId; + + final transitionState = ref.read(transitionProvider); + final transitionNotifier = ref.read(transitionProvider.notifier); + if (transitionState.active || + transitionState.phase == PageTransitionPhase.preparing) { + transitionNotifier.complete(); + } + + state = state.copyWith( + transitionState: direction == PageTransitionDirection.forward + ? PageTransitionState.animatingForward + : PageTransitionState.animatingBackward, + ); + + final startSettings = ref.read(strategySettingsProvider); + final previous = _snapshotAllPlaced(); + final strategyState = ref.read(strategyProvider); + final sourcePageId = state.activePageId; + var fadeInDrawings = false; + if (strategyState.source == StrategySource.local && + strategyState.strategyId != null) { + final strategy = Hive.box(HiveBoxNames.strategiesBox) + .get(strategyState.strategyId); + final targetPage = + strategy?.pages.where((page) => page.id == pageId).firstOrNull; + if (targetPage != null) { + fadeInDrawings = TransitionPlanner.drawingsChanged( + ref.read(drawingProvider).elements, + targetPage.drawingData, + ); + } + } + transitionNotifier.prepare( + previous.values.toList(), + direction: direction, + startAgentSize: startSettings.agentSize, + startAbilitySize: startSettings.abilitySize, + sourcePageId: sourcePageId, + targetPageId: pageId, + fadeInDrawings: fadeInDrawings, + ); + + try { + final switched = await _switchToPage( + pageId, + animated: true, + direction: direction, + ); + if (!switched) { + transitionNotifier.complete(); + state = state.copyWith(transitionState: PageTransitionState.idle); + return; + } + } catch (error, stackTrace) { + transitionNotifier.complete(); + final strategyState = ref.read(strategyProvider); + state = state.copyWith( + activePageId: previousPageId, + clearActivePageId: previousPageId == null, + transitionState: PageTransitionState.idle, + ); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyState.strategyId, + activePageId: previousPageId, + ); + if (strategyState.source == StrategySource.cloud) { + try { + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(previousPageId); + final strategyId = strategyState.strategyId; + if (strategyId != null && previousPageId != null) { + final snapshot = _lastAppliedRemoteSnapshot; + if (snapshot != null && + snapshot.header.publicId == strategyId && + snapshot.activePage?.page.publicId == previousPageId) { + ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( + strategyPublicId: strategyId, + pageId: previousPageId, + snapshot: snapshot, + ); + } + } + } catch (_) { + // Preserve the original switch failure; the live read can recover + // independently without leaving the transition state stuck. + } + } + _resumePendingRemoteReapplyIfPossible(); + Error.throwWithStackTrace(error, stackTrace); + } + WidgetsBinding.instance.addPostFrameCallback((_) async { + final preliminaryNext = _snapshotAllPlaced(); + final preliminaryEntries = _diffToTransitions(previous, preliminaryNext); + if (preliminaryEntries.isEmpty && !fadeInDrawings) { + transitionNotifier.complete(); + state = state.copyWith(transitionState: PageTransitionState.idle); + _resumePendingRemoteReapplyIfPossible(); + return; + } + + final needsAgentRouting = preliminaryEntries.any( + (entry) => + entry.kind == TransitionKind.move && + entry.visualWidget is PlacedAgentNode, + ); + // Agent routes load the small movement mesh independently of sightlines. + // Unchanged agents, abilities and drawings need no route initialization. + VisionGeometryMap? transitionGeometry; + NavigationGeometryMap? transitionNavigation; + final map = ref.read(mapProvider).currentMap; + final requireNavigation = ref.read(worldGeometryEnabledProvider(map)); + if (needsAgentRouting) { + try { + if (requireNavigation) { + transitionNavigation = + await ref.read(navigationGeometryProvider(map).future); + } else { + transitionGeometry = + await ref.read(viewConeGeometryProvider(map).future); + } + } on Object { + // The provider reports the failure. Required native routes hold at + // their sources, so changing pages cannot invent movement through walls. + } + } + + final currentTransition = ref.read(transitionProvider); + if (currentTransition.phase != PageTransitionPhase.preparing || + currentTransition.sourcePageId != sourcePageId || + currentTransition.targetPageId != pageId) { + return; + } + + final next = _snapshotAllPlaced(); + final entries = _diffToTransitions(previous, next); + if (entries.isEmpty && !fadeInDrawings) { + transitionNotifier.complete(); + state = state.copyWith(transitionState: PageTransitionState.idle); + _resumePendingRemoteReapplyIfPossible(); + return; + } + + final endSettings = ref.read(strategySettingsProvider); + final agentPaths = AgentTransitionPathPlanner.plan( + entries: entries, + geometry: transitionGeometry, + navigation: transitionNavigation, + requireNavigation: requireNavigation, + startAgentSize: startSettings.agentSize, + endAgentSize: endSettings.agentSize, + coordinateSystem: CoordinateSystem.instance, + ); + transitionNotifier.start( + entries, + duration: duration, + direction: direction, + startAgentSize: startSettings.agentSize, + endAgentSize: endSettings.agentSize, + startAbilitySize: startSettings.abilitySize, + endAbilitySize: endSettings.abilitySize, + sourcePageId: sourcePageId, + targetPageId: pageId, + agentPaths: agentPaths, + fadeInDrawings: fadeInDrawings, + ); + state = state.copyWith(transitionState: PageTransitionState.idle); + _resumePendingRemoteReapplyIfPossible(); + }); + } + + Future switchRelativePage(PageSwitchDirection direction) async { + if (state.availablePageIds.isEmpty) { + return; + } + + final active = state.activePageId ?? state.availablePageIds.first; + final currentIndex = state.availablePageIds.indexOf(active); + if (currentIndex < 0) { + return; + } + + final nextIndex = direction == PageSwitchDirection.next + ? (currentIndex + 1) % state.availablePageIds.length + : (currentIndex - 1 + state.availablePageIds.length) % + state.availablePageIds.length; + final nextPageId = state.availablePageIds[nextIndex]; + await setActivePageAnimated( + nextPageId, + direction: direction == PageSwitchDirection.next + ? PageTransitionDirection.forward + : PageTransitionDirection.backward, + ); + } + + Future flushCurrentPage({bool flushImmediately = false}) async { + final strategyState = ref.read(strategyProvider); + if (!strategyState.isOpen || strategyState.strategyId == null) { + return; + } + + final source = _resolvePageSource( + strategyState.strategyId!, + strategyState.source ?? StrategySource.local, + ); + if (strategyState.source == StrategySource.cloud) { + ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); + } + await source.flushCurrentPage(); + if (flushImmediately && strategyState.source == StrategySource.cloud) { + await ref.read(strategyOpQueueProvider.notifier).flushNow(); + } + } + + /// Keeps both versions of every conflicting lineup group: the cloud's + /// stays the group, and the user's goes beside it as a copy under fresh + /// ids (see forkLineUpGraph), so neither side's work is lost. A version + /// that deletes the group, or only reorders it, holds nothing to copy. + /// + /// Only for conflicts that are all lineup groups on the page on screen + /// (see lineupConflictsProvider); anything else is a [StateError]. + /// + /// Nothing is discarded unless everything still matches what was copied: + /// the cloud's version loads, the page on screen is the same one, the + /// waiting work is the same (an edit made meanwhile would not be in the + /// copy), and the copy itself saved. When the cloud's version cannot be + /// loaded, or the work changed before the copy was made, nothing changes. + /// When something fails after, the copy stays and the conflicts still + /// wait; pressing Keep both again resolves them without a second copy. + Future keepBothForRejected() async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + final queue = ref.read(strategyOpQueueProvider); + final attention = queue.attentionByEntityKey; + final activePageId = state.activePageId; + if (strategyState.source != StrategySource.cloud || + strategyId == null || + attention.isEmpty || + attention.keys.any((key) => + key.kind != EntitySyncKeyKind.lineup || + key.pageId != activePageId)) { + throw StateError('Keep both is only for lineup groups on this page'); + } + // A copy is only kept if it can be saved; while this device cannot + // save outbox records, nothing is copied and nothing discarded. + if (queue.hasDurabilityFailure) return KeepBothOutcome.unchanged; + final generation = _pageSessionGeneration; + final waiting = _waitingWork(attention.keys); + final copies = []; + final copied = <(EntitySyncKey, (String, String?)), List>{}; + for (final MapEntry(:key, value: refused) in attention.entries) { + final newest = (queue.successorByEntityKey[key] ?? refused).pending.op; + final payload = switch (newest) { + LineupAddOp(:final payload) => payload, + LineupPatchOp(:final payload?) => payload, + _ => null, + }; + final entry = (key, waiting[key]!); + if (payload == null) continue; + // A copy made by an earlier press stands while any of its lineups is + // on the canvas; once the user deleted it all, this press makes it + // again. + if (_keptAsCopy[entry] case final copyIds? + when copyIds.any( + (id) => ref.read(lineUpProvider).linkById(id) != null, + )) { + continue; + } + final copy = forkLineUpGraph(lineUpGraphFromCloudRows([ + CloudLineupRow(publicId: key.entityId!, payload: payload), + ]).graph); + if (copy.links.isEmpty) continue; + copies.add(copy); + copied[entry] = [for (final link in copy.links) link.id]; + } + bool unchangedSince() => + !_disposed && + generation == _pageSessionGeneration && + _sameWaitingWork(waiting); + + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.header.publicId != strategyId) { + return KeepBothOutcome.unchanged; + } + // Edits made while the cloud's version loaded are saved first; one to + // a conflicting group is newer than the copy, so nothing goes ahead. + final pageSource = _resolvePageSource(strategyId, StrategySource.cloud); + await pageSource.flushCurrentPage(); + if (!unchangedSince()) return KeepBothOutcome.unchanged; + + final lineUps = ref.read(lineUpProvider.notifier); + for (final copy in copies) { + lineUps.addRecovered(copy); + } + _keptAsCopy.addAll(copied); + // Every copy of this work, made now or by an earlier press. + final lineUpState = ref.read(lineUpProvider); + final copyLineupIds = [ + for (final MapEntry(:key, :value) in waiting.entries) + if (_keptAsCopy[(key, value)] case final ids?) + // One surviving lineup names the copy's group. + ids.firstWhere( + (id) => lineUpState.linkById(id) != null, + orElse: () => ids.first, + ), + ]; + await pageSource.flushCurrentPage(); + // A copy that could not be saved leaves the outbox failing (and waits in + // attention itself): nothing may be discarded on its behalf. + final afterCopy = ref.read(strategyOpQueueProvider); + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + final queuedKeys = { + for (final pending in afterCopy.pending) + EntitySyncKey.forStrategyOp(pending.op), + }; + // Each copy's group must be queued (or already on the server): a save + // skipped without failing, say after edit access was lost, leaves the + // copy on the canvas alone. + final copiesQueued = copyLineupIds.every((lineupId) { + final group = liveSync.lineupGroupOf(activePageId!, lineupId); + if (group == null) return false; + final key = EntitySyncKey.lineup(activePageId, group); + return queuedKeys.contains(key) || liveSync.hydratedBase(key) != null; + }); + final saved = copiesQueued && + !afterCopy.hasDurabilityFailure && + afterCopy.attentionByEntityKey.keys.every(waiting.containsKey); + if (!saved || !unchangedSince()) return KeepBothOutcome.copiesOnly; + return await _useCloudVersionsFor(waiting) + ? KeepBothOutcome.kept + : KeepBothOutcome.copiesOnly; + } + + /// The refused op, and the newer one queued behind it if any, of each of + /// [keys]: what is waiting for the user's choice. + Map _waitingWork( + Iterable keys, + ) { + final queue = ref.read(strategyOpQueueProvider); + return { + for (final key in keys) + if (queue.attentionByEntityKey[key] case final refused?) + key: ( + refused.pending.op.opId, + queue.successorByEntityKey[key]?.pending.op.opId, + ), + }; + } + + /// The canvas's content, one state object per kind of item on it. Every + /// edit replaces its kind's object, so comparing them by identity tells + /// whether anything changed in between. + List _canvasStates() => [ + ref.read(agentProvider), + ref.read(abilityProvider), + ref.read(drawingProvider), + ref.read(textProvider), + ref.read(placedImageProvider), + ref.read(utilityProvider), + ref.read(lineUpProvider), + ]; + + bool _sameCanvas(List saved) { + final now = _canvasStates(); + for (var i = 0; i < saved.length; i++) { + if (!identical(saved[i], now[i])) return false; + } + return true; + } + + /// Whether exactly [waiting] still waits, nothing changed or gone. + bool _sameWaitingWork(Map waiting) { + final now = _waitingWork(waiting.keys); + return now.length == waiting.length && + waiting.entries.every((entry) => now[entry.key] == entry.value); + } + + /// Adopts the cloud version for every current conflict in this strategy. + /// + /// The authoritative page is loaded before any local intent is discarded. + /// A failed load therefore leaves the durable conflict available to retry. + Future useCloudVersionsForRejected() => _useCloudVersionsFor(null); + + /// [useCloudVersionsForRejected], for exactly the conflicts in [waiting] + /// (by default, every conflict once the page's edits are saved). If they + /// no longer wait as they were, or the canvas changed after its last + /// save, nothing is discarded or redrawn: the newer work stays. + Future _useCloudVersionsFor( + Map? waiting, + ) async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyState.source != StrategySource.cloud || strategyId == null) { + return false; + } + + final generation = _pageSessionGeneration; + late Map resolving; + bool waitingChanged() => + _disposed || + generation != _pageSessionGeneration || + !_sameWaitingWork(resolving); + + _isResolvingConflicts = true; + try { + final conflictKeys = { + ...ref.read(strategyOpQueueProvider).attentionByEntityKey.keys, + }; + final currentPageSource = + _resolvePageSource(strategyId, StrategySource.cloud); + await currentPageSource.flushCurrentPage(); + final strategyNotifier = ref.read(strategyProvider.notifier); + strategyNotifier.consumeScheduledCloudPageSync(); + strategyNotifier.consumeScheduledCloudStrategySync(); + resolving = waiting ?? + _waitingWork( + ref.read(strategyOpQueueProvider).attentionByEntityKey.keys, + ); + if (waitingChanged()) return false; + final rejected = Map.from( + ref.read(strategyOpQueueProvider).attentionByEntityKey, + )..removeWhere((key, _) => !resolving.containsKey(key)); + // Conflicts the save turned back into queued work (a record whose + // removal failed before, say) were not resolved: that is no success. + // Ones it settled (gone on both sides) are. + if (rejected.isEmpty) { + final pending = { + for (final work in ref.read(strategyOpQueueProvider).pending) + EntitySyncKey.forStrategyOp(work.op), + }; + return !conflictKeys.any(pending.contains); + } + + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.header.publicId != strategyId) { + return false; + } + + // Edits made while the cloud's version loaded are saved, and one to + // the waiting work stops the resolution: the page drawn next would + // replace that edit on screen. The canvas is noted before that save, + // so an edit made while it is being written counts as a change too. + final savedCanvas = _canvasStates(); + final queueNotifier = ref.read(strategyOpQueueProvider.notifier); + final failuresBefore = queueNotifier.persistenceFailureCount; + await currentPageSource.flushCurrentPage(); + // A save that failed just now may have dropped a newer edit the queue + // never recorded; nothing is discarded or redrawn over it. (A failure + // left from before, say a discard that did not finish, does not stop + // the discard that can clear it.) + if (waitingChanged() || + queueNotifier.persistenceFailureCount != failuresBefore) { + return false; + } + final targetPageId = _resolveHydrationTargetPage(snapshot); + final localMetadata = _unsentLocalMetadata(); + if (targetPageId != null) { + final pageSource = CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + final pageData = await pageSource.loadAuthoritativePage( + targetPageId, + discardedEntities: rejected.keys.toSet(), + ); + // An edit since the last save is still only on the canvas, and + // stops the redraw; checked again where the redraw happens, after + // the page list it awaits. + bool unchangedCanvas() => !waitingChanged() && _sameCanvas(savedCanvas); + if (!unchangedCanvas()) return false; + var redrawn = false; + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + canApply: () => redrawn = unchangedCanvas(), + hydrationKey: _buildRemotePageHydrationKey(snapshot, targetPageId), + preserveTextDrafts: true, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + preservedMetadata: + rejected.containsKey(const EntitySyncKey.strategy()) + ? null + : localMetadata, + ); + if (!redrawn) return false; + } + // What the first redraw left: a redraw after a partial discard below + // draws only over this, never over an edit made while discarding. + final redrawnCanvas = _canvasStates(); + + final discarded = await ref + .read(strategyOpQueueProvider.notifier) + .discardRejected(rejected.keys.toSet(), onlyIf: resolving); + // A failed durable delete keeps its overlay and conflict. Restore those + // entities if only part of the requested adoption could be saved. + if (discarded.length != rejected.length && targetPageId != null) { + final pageSource = CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + // Redrawn only if nothing was edited since the first redraw. + final pageData = await pageSource.loadAuthoritativePage( + targetPageId, + discardedEntities: discarded, + ); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + canApply: () => + !_disposed && + generation == _pageSessionGeneration && + _sameCanvas(redrawnCanvas), + preserveTextDrafts: true, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + preservedMetadata: discarded.contains(const EntitySyncKey.strategy()) + ? null + : localMetadata, + ); + } + if (discarded.isEmpty) return false; + + ref.read(activePageLiveSyncProvider.notifier).adoptRemoteForEntities( + discarded, + hydratedPageId: targetPageId, + ); + for (final entry in rejected.entries) { + if (discarded.contains(entry.key)) { + ref + .read(strategyConflictProvider.notifier) + .clear(entry.value.pending.op.opId); + } + } + for (final key in discarded) { + if (key.kind == EntitySyncKeyKind.element && key.entityId != null) { + ref.read(textDraftProvider.notifier).clearDraft(key.entityId!); + } + } + ref + .read(strategyOpQueueProvider.notifier) + .completeRemoteAdoption(discarded); + _pendingRemoteReapply = false; + return discarded.length == rejected.length; + } finally { + _isResolvingConflicts = false; + } + } + + /// The strategy's map and theme on screen, while a change to them is still + /// on its way: loading a page must keep them rather than take the older + /// server copy (and so drop the change). + ({MapValue map, StrategyThemeState theme})? _unsentLocalMetadata() { + final hasPendingMetadata = ref.read(strategyOpQueueProvider).pending.any( + (pending) { + final op = pending.op; + return op is StrategyPatchOp && + op.payload.keys.any((key) => + key == 'mapData' || + key == 'themeProfileId' || + key == 'clearThemeProfileId' || + key == 'themeOverridePalette' || + key == 'clearThemeOverridePalette'); + }, + ); + return hasPendingMetadata + ? ( + map: ref.read(mapProvider).currentMap, + theme: ref.read(strategyThemeProvider), + ) + : null; + } + + bool get isApplyingPage => state.isApplyingPage; + + void setStateForTest(StrategyPageSessionState newState) { + state = newState; + } + + void reset() { + _remoteChangeWaitsForEditor = false; + _pageSessionGeneration++; + state = const StrategyPageSessionState( + activePageId: null, + availablePageIds: [], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + _lastHydratedRemotePageKey = null; + _lastAppliedRemoteSnapshot = null; + _pendingRemoteReapply = false; + _isResolvingConflicts = false; + ref.read(activePageLiveSyncProvider.notifier).reset(); + } + + /// Returns false, leaving the page on screen, if a teammate deleted it + /// while its work was being flushed. + Future _switchToPage( + String pageId, { + required bool animated, + PageTransitionDirection? direction, + }) async { + _pageSessionGeneration++; + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + final source = strategyState.source; + if (strategyId == null || source == null) { + return true; + } + + final pageSource = _resolvePageSource(strategyId, source); + if (source == StrategySource.cloud) { + ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); + } + await pageSource.flushCurrentPage(); + if (source == StrategySource.cloud) { + await ref + .read(strategyOpQueueProvider.notifier) + .flushNow() + .timeout(const Duration(milliseconds: 750), onTimeout: () {}); + // The page may have been deleted while its work was flushed. + if (_deletedPageHoldsWork()) return false; + state = state.copyWith(activePageId: pageId); + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: pageId, + ); + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(pageId); + } + + final pageData = await pageSource.loadPage(pageId); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: source, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + ); + + if (animated && direction != null) { + _updateHydrationBookkeeping(pageData.pageId); + } + return true; + } + + Future _rehydrateActivePageFromSource( + String pageId, { + _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, + }) async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + final source = strategyState.source; + if (strategyId == null || source == null) { + return; + } + + ref.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: strategyId, + activePageId: pageId, + ); + if (source == StrategySource.cloud) { + ref.read(activePageLiveSyncProvider.notifier).markPageUnhydrated( + strategyPublicId: strategyId, + pageId: pageId, + ); + } + final pageSource = _resolvePageSource(strategyId, source); + final pageData = await pageSource.loadPage(pageId); + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: source, + hydrationKey: hydrationKey, + preserveTextDrafts: preserveTextDrafts, + loadedRemoteSnapshot: pageSource.loadedRemoteSnapshot, + ); + } + + Future _applyLoadedPageData( + StrategyEditorPageData pageData, { + required String strategyId, + required StrategySource source, + _RemotePageHydrationKey? hydrationKey, + bool preserveTextDrafts = false, + RemoteEditorSnapshot? loadedRemoteSnapshot, + ({MapValue map, StrategyThemeState theme})? preservedMetadata, + bool Function()? canApply, + }) async { + final availablePageIds = + await _resolvePageSource(strategyId, source).listPageIds(); + if (canApply != null && !canApply()) return; + final preserveHistory = source == StrategySource.cloud && + _lastHydratedRemotePageKey?.strategyPublicId == strategyId && + _lastHydratedRemotePageKey?.pageId == pageData.pageId; + final themeProfileId = preservedMetadata != null + ? preservedMetadata.theme.profileId + : _resolveThemeProfileId(source, strategyId); + final themeOverridePalette = preservedMetadata != null + ? preservedMetadata.theme.overridePalette + : _resolveThemeOverridePalette(source, strategyId); + + state = state.copyWith( + isApplyingPage: true, + activePageId: pageData.pageId, + availablePageIds: availablePageIds, + ); + + final retainedTextDrafts = preserveTextDrafts + ? Map.from(ref.read(textDraftProvider)) + : const {}; + try { + await applyStrategyEditorPageData( + ref, + pageData, + themeProfileId: themeProfileId, + themeOverridePalette: themeOverridePalette, + preserveHistory: preserveHistory, + mapOverride: preservedMetadata?.map, + ); + for (final entry in retainedTextDrafts.entries) { + ref.read(textDraftProvider.notifier).setDraft(entry.key, entry.value); + } + if (source == StrategySource.cloud) { + if (loadedRemoteSnapshot == null) { + throw StateError( + 'Cloud page loaded without its source snapshot.', + ); + } + ref.read(activePageLiveSyncProvider.notifier).markPageHydrated( + strategyPublicId: strategyId, + pageId: pageData.pageId, + snapshot: loadedRemoteSnapshot, + ); + _lastAppliedRemoteSnapshot = loadedRemoteSnapshot; + } + _updateHydrationBookkeeping( + pageData.pageId, + hydrationKey: source == StrategySource.cloud + ? _buildRemotePageHydrationKey( + loadedRemoteSnapshot!, + pageData.pageId, + ) + : hydrationKey, + ); + } finally { + state = state.copyWith( + activePageId: pageData.pageId, + isApplyingPage: false, + ); + _resumePendingRemoteReapplyIfPossible(); + } + } + + StrategyPageSource _resolvePageSource( + String strategyId, + StrategySource source, + ) { + switch (source) { + case StrategySource.local: + return LocalStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + case StrategySource.cloud: + return CloudStrategyPageSource( + ref, + strategyId: strategyId, + activePageId: () => state.activePageId, + ); + } + } + + String _resolveThemeProfileId(StrategySource source, String strategyId) { + if (source == StrategySource.cloud) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + return snapshot?.header.themeProfileId ?? + MapThemeProfilesProvider.immutableDefaultProfileId; + } + + final strategy = Hive.box(HiveBoxNames.strategiesBox).get( + strategyId, + ); + return strategy?.themeProfileId ?? + MapThemeProfilesProvider.immutableDefaultProfileId; + } + + MapThemePalette? _resolveThemeOverridePalette( + StrategySource source, + String strategyId, + ) { + if (source == StrategySource.cloud) { + final payload = ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.header + .themeOverridePalette; + if (payload == null || payload.isEmpty) { + return null; + } + try { + return MapThemePalette.fromJson(payload); + } catch (_) { + return null; + } + } + + return Hive.box(HiveBoxNames.strategiesBox) + .get(strategyId) + ?.themeOverridePalette; + } + + bool _canSafelyReapplyRemotePage() { + final saveState = ref.read(strategySaveStateProvider); + return !_isResolvingConflicts && + state.deletedPage == null && + !state.isApplyingPage && + state.transitionState == PageTransitionState.idle && + ref.read(editorHeldEntitiesProvider) != null && + !saveState.isDirty && + !saveState.isSaving && + !saveState.hasPendingCloudSync; + } + + void _requestRemoteRehydrate(String pageId) { + if (_canSafelyReapplyRemotePage()) { + unawaited(_reapplyRemotePage(pageId)); + } else { + _pendingRemoteReapply = true; + _checkActivePageOnServer(); + } + } + + /// A replace of the page on screen is waiting. If it waits on work for a + /// page a teammate deleted, the user is told now, unless they are + /// mid-gesture: the gesture ending asks again. + void _checkActivePageOnServer() { + if (_isResolvingConflicts || + state.isApplyingPage || + state.transitionState != PageTransitionState.idle || + ref.read(editorBusyProvider)) { + return; + } + _deletedPageHoldsWork(); + } + + /// Whether the canvas is on a page a teammate deleted, holding work the + /// server never got. That work can never be sent, so waiting for it would + /// wait forever and replacing the page would drop it unseen: the user is + /// told first ([StrategyPageSessionState.deletedPage]). Checked before the + /// canvas leaves the page, and while a replace of it waits. With nothing + /// unsent, the unsaved mark its edits left is stale and is dropped. + bool _deletedPageHoldsWork() { + if (state.deletedPage != null) return true; + final pageId = state.activePageId; + final strategy = ref.read(strategyProvider); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (pageId == null || + !strategy.isOpen || + strategy.source != StrategySource.cloud || + snapshot == null || + snapshot.header.publicId != strategy.strategyId || + snapshot.pages.any((page) => page.publicId == pageId) || + _lastHydratedRemotePageKey?.pageId != pageId) { + return false; + } + // The user deleted it here: their delete landed, or is still on its way. + // Work on it the server paused or refused stays in the sync status. + final queue = ref.read(strategyOpQueueProvider); + _notePagesDeletedHere(queue); + if (_deletedHere(pageId)) return false; + final descriptor = EntitySyncKey.pageDescriptor(pageId); + if ([ + queue.queuedByEntityKey[descriptor]?.pending.op, + queue.inFlightByEntityKey[descriptor]?.pending.op, + queue.successorByEntityKey[descriptor]?.pending.op, + ].any((op) => op is PageDeleteOp)) { + return false; + } + if (!ref.read(activePageLiveSyncProvider.notifier).hasUnsentWork(pageId)) { + ref.read(strategySaveStateProvider.notifier).clearStaleCloudMark(); + return false; + } + final lastSeen = _lastAppliedRemoteSnapshot?.pages + .where((page) => page.publicId == pageId) + .firstOrNull; + state = state.copyWith( + deletedPage: (pageId: pageId, name: lastSeen?.name ?? 'This page'), + ); + return true; + } + + /// Records the pages whose delete the server just accepted from [queue]. + /// Called from the check as well as the queue listener: a listener + /// registered earlier (save state) can run the check first. + void _notePagesDeletedHere(StrategyOpQueueState queue) { + for (final acked in queue.lastAckBatch) { + // Only a delete the server applied took the page away. A no-op says + // it was already gone, not by whom: a teammate may have deleted it + // since, and their delete must still show the notice. + if ((acked.op, acked.ack) + case ( + PageDeleteOp(:final pagePublicId), + AppliedOpAck(:final revision), + )) { + _pagesDeletedHere[pagePublicId] = revision; + } + } + } + + /// Whether [pageId]'s disappearing is this device's delete: one the server + /// applied that is newer than the page was last seen live. Seen live at + /// the delete's revision or later, the page was restored after it (the + /// delete's own revision never lists it), and a later delete is someone's + /// new one, whichever order the ack and the reads arrive in. + bool _deletedHere(String pageId) { + final deletedAt = _pagesDeletedHere[pageId]; + return deletedAt != null && (_pagesSeenLiveAt[pageId] ?? -1) < deletedAt; + } + + /// Lets the deleted page on screen go, with the unsaved work on it, and + /// puts a page the server has on screen. Returns false, staying on the + /// deleted page, while some of that work is still on its way and cannot be + /// taken back, or if no other page could be loaded. + Future leaveDeletedPage() async { + final deleted = state.deletedPage; + final strategyId = ref.read(strategyProvider).strategyId; + if (deleted == null || strategyId == null) return false; + if (!await _withdrawPageWork(deleted.pageId) || + state.deletedPage != deleted) { + return false; + } + // Images only that work placed have nothing left to show them. + unawaited(ref + .read(cloudMediaUploadQueueProvider.notifier) + .recheckAfterDiscardedWork(strategyId)); + // A read that failed earlier would otherwise fail every retry. + final remote = ref.read(remoteEditorSnapshotProvider.notifier); + await remote.refresh(); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final target = + snapshot == null ? null : _resolveHydrationTargetPage(snapshot); + if (target == null || state.deletedPage != deleted) return false; + // Loaded here, not through the reapply that waits for pending cloud + // work: work queued for other pages is not on this canvas, and the + // deleted page must not stay editable while it waits. If the page is + // back (restored meanwhile), this is its server copy, without the + // discarded work. + try { + await remote.setActivePage(target); + final source = _resolvePageSource(strategyId, StrategySource.cloud); + final pageData = await source.loadPage(target); + if (state.deletedPage != deleted) return false; + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + loadedRemoteSnapshot: source.loadedRemoteSnapshot, + preservedMetadata: _unsentLocalMetadata(), + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Could not load a page to replace the deleted one.', + error: error, + stackTrace: stackTrace, + source: 'strategy_page_session:leave_deleted_page', + promptUser: false, + ); + return false; + } + ref.read(strategySaveStateProvider.notifier).clearStaleCloudMark(); + state = state.copyWith(clearDeletedPage: true); + return true; + } + + /// Brings the deleted page on screen back from the server's trash, with + /// everything it had there, so the unsaved work on it saves as usual: the + /// changes the server refused while it was in the trash are sent again, + /// and the canvas's other edits are queued against the restored copy. The + /// notice stays until the page is back on screen, read live. + Future restoreDeletedPage() async { + final deleted = state.deletedPage; + final strategyId = ref.read(strategyProvider).strategyId; + if (deleted == null || strategyId == null) { + return DeletedPageRestore.failed; + } + final outcome = await _restoreOnServer(strategyId, deleted.pageId); + if (outcome != DeletedPageRestore.restored) return outcome; + // A change still on its way when the page came back may yet be refused; + // its answer comes before the refused changes are sent again. + final settled = await _queueSettles((queue) => !queue + .inFlightByEntityKey.keys + .any((key) => key.pageId == deleted.pageId)); + // Its answer never came: a refusal still on its way would miss the + // retry below. Restoring again is safe once it has. + if (!settled) return DeletedPageRestore.failed; + await ref + .read(remoteEditorSnapshotProvider.notifier) + .showRestoredPage(deleted.pageId); + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (state.deletedPage != deleted || + snapshot == null || + snapshot.activePage?.page.publicId != deleted.pageId || + !snapshot.pages.any((page) => page.publicId == deleted.pageId)) { + return DeletedPageRestore.failed; + } + // Loading the page that was to replace it moved live sync off it. + ref + .read(activePageLiveSyncProvider.notifier) + .resumePage(strategyPublicId: strategyId, pageId: deleted.pageId); + state = state.copyWith(clearDeletedPage: true); + await ref + .read(strategyOpQueueProvider.notifier) + .retryRestoredPage(deleted.pageId); + await flushCurrentPage(flushImmediately: true); + // A send whose answer was lost replays under its own op id, and may be + // answered with the refusal the server recorded while the page was in + // the trash: once the page's sends are answered, those go again too. + // Sends still unanswered after the wait stay queued, saving as usual; + // a late refusal among them shows in the sync status, where Keep mine + // sends it again. + if (await _queueSettles((queue) => ![ + queue.queuedByEntityKey, + queue.inFlightByEntityKey, + ].any((sends) => + sends.keys.any((key) => key.pageId == deleted.pageId)))) { + await ref + .read(strategyOpQueueProvider.notifier) + .retryRestoredPage(deleted.pageId); + } + // A later read may have shown the page gone again. + return state.deletedPage == null && state.activePageId == deleted.pageId + ? DeletedPageRestore.restored + : DeletedPageRestore.failed; + } + + /// Brings [pageId] back from the server's trash, from Recently deleted: at + /// the place it was deleted from, with everything on it. Changes to it the + /// server refused while it was in the trash are sent again, once any + /// still on their way have been answered, and again once the page's + /// queued sends have gone: a send whose answer was lost replays under its + /// own op id, and may bring back the refusal. Refusals not answered in + /// time stay in the sync status, where Keep mine sends them. + Future restorePageFromTrash(String pageId) async { + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return DeletedPageRestore.failed; + final outcome = await _restoreOnServer(strategyId, pageId); + if (outcome != DeletedPageRestore.restored) return outcome; + final queue = ref.read(strategyOpQueueProvider.notifier); + bool onPage(EntitySyncKey key) => key.pageId == pageId; + if (await _queueSettles( + (state) => !state.inFlightByEntityKey.keys.any(onPage))) { + await queue.retryRestoredPage(pageId); + } + if (await _queueSettles((state) => ![ + state.queuedByEntityKey, + state.inFlightByEntityKey, + ].any((sends) => sends.keys.any(onPage)))) { + await queue.retryRestoredPage(pageId); + } + return DeletedPageRestore.restored; + } + + /// Asks the server to take [pageId] out of its trash. + Future _restoreOnServer( + String strategyId, + String pageId, + ) async { + try { + await ref.read(convexStrategyRepositoryProvider).restorePage( + strategyPublicId: strategyId, + pagePublicId: pageId, + ); + return DeletedPageRestore.restored; + } catch (error, stackTrace) { + if (isTypedConvexNotFoundError(error)) return DeletedPageRestore.gone; + AppErrorReporter.reportError( + 'Could not restore a deleted page.', + error: error, + stackTrace: stackTrace, + source: 'strategy_page_session:restore_page', + promptUser: false, + ); + return DeletedPageRestore.failed; + } + } + + Future _reapplyRemotePage(String pageId) async { + if (_remoteReapplyInFlight || !_canSafelyReapplyRemotePage()) { + _pendingRemoteReapply = true; + return; + } + final strategy = ref.read(strategyProvider); + final strategyId = strategy.strategyId; + if (!strategy.isOpen || + strategy.source != StrategySource.cloud || + strategyId == null) return; + final generation = _pageSessionGeneration; + final startingPageId = state.activePageId; + // The clean canvas is about to consume the server copy. A previously + // scheduled diff must not re-author that already-saved work while loading. + ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); + _remoteReapplyInFlight = true; + _pendingRemoteReapply = false; + try { + final source = _resolvePageSource(strategyId, StrategySource.cloud); + final pageData = await source.loadPage(pageId); + if (_disposed || generation != _pageSessionGeneration) return; + bool canApply() { + if (_disposed || generation != _pageSessionGeneration) return false; + final current = ref.read(strategyProvider); + if (!current.isOpen || + current.strategyId != strategyId || + current.source != StrategySource.cloud || + state.activePageId != startingPageId) return false; + // Loading yields. A new gesture, local commit, or newer remote + // snapshot may have arrived since the request started. + final loadedKey = _buildRemotePageHydrationKey( + source.loadedRemoteSnapshot!, + pageId, + ); + if (!_canSafelyReapplyRemotePage() || + loadedKey != _currentRemotePageHydrationKey(pageId)) { + _pendingRemoteReapply = true; + return false; + } + return true; + } + + final loadedSnapshot = source.loadedRemoteSnapshot!; + final alreadyOnScreen = + _lastHydratedRemotePageKey?.strategyPublicId == strategyId && + _lastHydratedRemotePageKey?.pageId == pageId && + ref.read(activePageLiveSyncProvider).hydratedPageId == pageId; + if (alreadyOnScreen) { + if (canApply()) { + _mergeRemotePage( + pageData, + strategyId: strategyId, + snapshot: loadedSnapshot, + ); + } + return; + } + await _applyLoadedPageData( + pageData, + strategyId: strategyId, + source: StrategySource.cloud, + loadedRemoteSnapshot: loadedSnapshot, + // Replacing the page (say a teammate deleted the one on screen) + // clears everything, so it waits until nothing is mid-way. + canApply: () { + if (ref.read(editorBusyProvider)) { + _remoteChangeWaitsForEditor = true; + return false; + } + if (_deletedPageHoldsWork()) return false; + return canApply(); + }, + ); + } finally { + _remoteReapplyInFlight = false; + if (!_disposed) _resumePendingRemoteReapplyIfPossible(); + } + } + + /// Brings the page on screen up to [snapshot] item by item, leaving what + /// the user is in the middle of changing. A held item's change applies once + /// the hold ends. + void _mergeRemotePage( + StrategyEditorPageData pageData, { + required String strategyId, + required RemoteEditorSnapshot snapshot, + }) { + final pageId = pageData.pageId; + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + // Marks these writes as the server's, not edits to send back. + state = state.copyWith(isApplyingPage: true); + final Set heldBack; + try { + heldBack = mergeRemoteStrategyEditorPageData( + ref, + pageData, + changed: liveSync.remoteChangesSinceHydration(snapshot, pageId), + holding: ref.read(editorHeldEntitiesProvider)!, + themeProfileId: + _resolveThemeProfileId(StrategySource.cloud, strategyId), + themeOverridePalette: _resolveThemeOverridePalette( + StrategySource.cloud, + strategyId, + ), + ); + } finally { + state = state.copyWith(isApplyingPage: false); + } + liveSync.markPageHydrated( + strategyPublicId: strategyId, + pageId: pageId, + snapshot: snapshot, + keepBaseFor: heldBack, + ); + _lastAppliedRemoteSnapshot = snapshot; + _updateHydrationBookkeeping( + pageId, + hydrationKey: _buildRemotePageHydrationKey(snapshot, pageId), + ); + if (heldBack.isNotEmpty) _remoteChangeWaitsForEditor = true; + } + + String? _resolveHydrationTargetPage(RemoteEditorSnapshot snapshot) { + if (snapshot.pages.isEmpty) { + return null; + } + + final activePageId = state.activePageId; + if (activePageId != null && + snapshot.pages.any((page) => page.publicId == activePageId)) { + return activePageId; + } + + final pages = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + return pages.first.publicId; + } + + void _updateHydrationBookkeeping( + String pageId, { + _RemotePageHydrationKey? hydrationKey, + }) { + final key = hydrationKey ?? _currentRemotePageHydrationKey(pageId); + if (key == null) { + return; + } + _lastHydratedRemotePageKey = key; + } + + _RemotePageHydrationKey? _currentRemotePageHydrationKey(String pageId) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null) { + return null; + } + return _buildRemotePageHydrationKey(snapshot, pageId); + } + + _RemotePageHydrationKey? _buildRemotePageHydrationKey( + RemoteEditorSnapshot snapshot, + String pageId, + ) { + RemotePage? page; + for (final candidate in snapshot.pages) { + if (candidate.publicId == pageId) { + page = candidate; + break; + } + } + if (page == null) { + return null; + } + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != pageId) { + return null; + } + + final elements = [ + ...snapshot.elementsByPage[pageId] ?? const [] + ]..sort(_compareRemoteElements); + final lineups = [ + ...snapshot.lineupsByPage[pageId] ?? const [] + ]..sort(_compareRemoteLineups); + final assets = snapshot.assetsById.values.toList() + ..sort((a, b) => a.publicId.compareTo(b.publicId)); + + final fingerprint = jsonEncode({ + 'page': { + 'publicId': page.publicId, + 'name': page.name, + 'sortIndex': page.sortIndex, + 'isAttack': page.isAttack, + 'revision': page.revision, + 'contentRevision': pageSnapshot.content.revision, + 'settings': pageSnapshot.content.settings, + }, + 'elements': [ + for (final element in elements) + { + 'publicId': element.publicId, + 'elementType': element.elementType, + 'payload': element.payload, + 'sortIndex': element.sortIndex, + 'revision': element.revision, + 'deleted': element.deleted, + }, + ], + 'lineups': [ + for (final lineup in lineups) + { + 'publicId': lineup.publicId, + 'payload': lineup.payload, + 'sortIndex': lineup.sortIndex, + 'revision': lineup.revision, + 'deleted': lineup.deleted, + }, + ], + 'assets': [ + for (final asset in assets) + { + 'publicId': asset.publicId, + 'fileExtension': asset.fileExtension, + 'mimeType': asset.mimeType, + 'width': asset.width, + 'height': asset.height, + 'url': asset.url, + 'legacyStoragePath': asset.legacyStoragePath, + }, + ], + }); + + return _RemotePageHydrationKey( + strategyPublicId: snapshot.header.publicId, + pageId: pageId, + fingerprint: fingerprint, + ); + } + + int _compareRemoteElements(RemoteElement a, RemoteElement b) { + final sortCompare = a.sortIndex.compareTo(b.sortIndex); + if (sortCompare != 0) { + return sortCompare; + } + return a.publicId.compareTo(b.publicId); + } + + int _compareRemoteLineups(RemoteLineup a, RemoteLineup b) { + final sortCompare = a.sortIndex.compareTo(b.sortIndex); + if (sortCompare != 0) { + return sortCompare; + } + return a.publicId.compareTo(b.publicId); + } + + Future _reconcileAcks( + List acks, + List ackBatch, + ) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || acks.isEmpty) { + return; + } + + ref.read(activePageLiveSyncProvider.notifier).recordAckBatch(ackBatch); + var hasReject = false; + for (final ack in acks) { + if (ack.isAck) { + continue; + } + hasReject = true; + Map? serverPayload; + if (ack.latestPayload != null && ack.latestPayload!.isNotEmpty) { + serverPayload = cloudPayloadData(ack.latestPayload); + } + + ref.read(strategyConflictProvider.notifier).push( + ConflictResolution( + type: ConflictResolutionType.rebase, + opId: ack.opId, + message: ack.reason, + serverPayload: serverPayload, + serverRevision: ack.latestRevision, + ), + ); + } + + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + if (_remoteReapplyInFlight || !_canSafelyReapplyRemotePage()) { + _pendingRemoteReapply = true; + return; + } + final activePageId = state.activePageId; + final strategyId = strategyState.strategyId; + if (activePageId != null && strategyId != null) { + ref.read(strategyProvider.notifier).consumeScheduledCloudPageSync(); + final desiredOpsByEntityKey = + ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: strategyId, + pageId: activePageId, + ); + if (desiredOpsByEntityKey != null) { + await ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + pageId: activePageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + flushImmediately: false, + ); + } + if (_canSafelyReapplyRemotePage()) { + await _reapplyRemotePage(activePageId); + } else { + _pendingRemoteReapply = true; + } + } else if (hasReject) { + _pendingRemoteReapply = true; + } + } + + /// How long leaving a deleted page waits on work already sent for it. + @visibleForTesting + Duration pageWorkSettleTimeout = const Duration(seconds: 10); + + /// Takes every op for [pageId] out of the queue, with the local intent + /// (overlays) they carried. An op already sent cannot be taken back, so + /// this waits for it to be answered, then takes out what it left. Returns + /// whether the queue holds nothing for the page. + Future _withdrawPageWork(String pageId) async { + final queue = ref.read(strategyOpQueueProvider.notifier); + if (!await queue.discardDeletedPage(pageId)) { + await _queueSettles((state) => + !state.inFlightByEntityKey.keys.any((key) => key.pageId == pageId)); + await queue.discardDeletedPage(pageId); + } + ref.read(activePageLiveSyncProvider.notifier).dropSatisfiedOverlays(pageId); + final state = ref.read(strategyOpQueueProvider); + return ![ + state.queuedByEntityKey, + state.inFlightByEntityKey, + state.successorByEntityKey, + state.pausedByEntityKey, + state.attentionByEntityKey, + ].any((intents) => intents.keys.any((key) => key.pageId == pageId)); + } + + /// Waits, at most [pageWorkSettleTimeout], for the op queue to satisfy + /// [settled]. Returns whether it did. + Future _queueSettles( + bool Function(StrategyOpQueueState queue) settled, + ) async { + if (settled(ref.read(strategyOpQueueProvider))) return true; + final done = Completer(); + final subscription = ref.listen( + strategyOpQueueProvider, + (_, next) { + if (settled(next) && !done.isCompleted) done.complete(); + }, + ); + try { + await done.future.timeout(pageWorkSettleTimeout, onTimeout: () {}); + } finally { + subscription.close(); + } + return done.isCompleted; + } + + void _resumePendingRemoteReapplyIfPossible() { + if (_disposed || _remoteReapplyInFlight || !_pendingRemoteReapply) { + return; + } + if (!_canSafelyReapplyRemotePage()) { + _checkActivePageOnServer(); + return; + } + _pendingRemoteReapply = false; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final pageId = + snapshot == null ? null : _resolveHydrationTargetPage(snapshot); + if (pageId != null) { + unawaited( + _reapplyRemotePage(pageId), + ); + } + } + + Map _snapshotAllPlaced() { + final map = {}; + for (final agent in ref.read(agentProvider)) { + map[agent.id] = agent; + } + for (final ability in ref.read(abilityProvider)) { + map[ability.id] = ability; + } + for (final text in ref.read(textProvider)) { + map[text.id] = text; + } + for (final image in ref.read(placedImageProvider).images) { + map[image.id] = image; + } + for (final utility in ref.read(utilityProvider)) { + map[utility.id] = utility; + } + return map; + } + + List _diffToTransitions( + Map previous, + Map next, + ) => + TransitionPlanner.diff(previous, next); +} diff --git a/lib/providers/strategy_provider.dart b/lib/providers/strategy_provider.dart index 903b0527..6b2b4653 100644 --- a/lib/providers/strategy_provider.dart +++ b/lib/providers/strategy_provider.dart @@ -1,368 +1,88 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'package:archive/archive_io.dart'; -import 'package:cross_file/cross_file.dart'; import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; -import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/const/transition_data.dart'; -import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/abilities.dart'; -import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/migrations/ability_vision_cone_migration.dart'; -import 'package:icarus/migrations/agent_weapon_migration.dart'; -import 'package:icarus/migrations/ability_scale_migration.dart'; -import 'package:icarus/migrations/canonical_coordinates_migration.dart'; -import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; -import 'package:icarus/migrations/lineup_graph_migration.dart'; -import 'package:icarus/migrations/lineup_group_migration.dart'; -import 'package:icarus/migrations/page_name_provenance_migration.dart'; -import 'package:icarus/migrations/paranoia_range_migration.dart'; -import 'package:icarus/migrations/sunset_scale_migration.dart'; import 'package:icarus/providers/ability_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/favorite_agents_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/services/app_error_reporter.dart'; -import 'package:icarus/services/analytics_service.dart'; -import 'package:hive_ce/hive.dart'; -import 'package:icarus/const/drawing_element.dart'; -import 'package:icarus/const/maps.dart'; -import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/providers/transition_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; -import 'package:icarus/providers/view_cone_geometry_provider.dart'; -import 'package:icarus/providers/navigation_geometry_provider.dart'; -import 'package:icarus/page_transition/navigation_geometry_map.dart'; -import 'package:icarus/page_transition/agent_path.dart'; -import 'package:icarus/page_transition/transition_planner.dart'; -import 'package:icarus/services/archive_manifest.dart'; -import 'package:icarus/view_cone/vision_geometry.dart'; +import 'package:hive_ce/hive.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; -class StrategyData extends HiveObject { - final String id; - String name; - final int versionNumber; - - @Deprecated('Use pages instead') - final List drawingData; - - @Deprecated('Use pages instead') - final List agentData; - - @Deprecated('Use pages instead') - final List abilityData; - - @Deprecated('Use pages instead') - final List textData; - - @Deprecated('Use pages instead') - final List imageData; - - @Deprecated('Use pages instead') - final List utilityData; - - @Deprecated('Use pages instead') - final bool isAttack; - - @Deprecated('Use pages instead') - final StrategySettings strategySettings; - - final List pages; - final MapValue mapData; - final DateTime lastEdited; - final DateTime createdAt; - - String? folderID; - final String? themeProfileId; - final MapThemePalette? themeOverridePalette; - - StrategyData({ - @Deprecated('Use pages instead') this.isAttack = true, - @Deprecated('Use pages instead') this.drawingData = const [], - @Deprecated('Use pages instead') this.agentData = const [], - @Deprecated('Use pages instead') this.abilityData = const [], - @Deprecated('Use pages instead') this.textData = const [], - @Deprecated('Use pages instead') this.imageData = const [], - @Deprecated('Use pages instead') this.utilityData = const [], - required this.id, - required this.name, - required this.mapData, - required this.versionNumber, - required this.lastEdited, - required this.folderID, - this.themeProfileId, - this.themeOverridePalette, - this.pages = const [], - DateTime? createdAt, - @Deprecated('Use pages instead') StrategySettings? strategySettings, - // ignore: deprecated_member_use_from_same_package - }) : strategySettings = strategySettings ?? StrategySettings(), - createdAt = createdAt ?? lastEdited; - - StrategyData copyWith({ - String? id, - String? name, - int? versionNumber, - List? drawingData, - List? agentData, - List? abilityData, - List? textData, - List? imageData, - List? utilityData, - List? pages, - MapValue? mapData, - DateTime? lastEdited, - bool? isAttack, - StrategySettings? strategySettings, - String? folderID, - DateTime? createdAt, - String? themeProfileId, - bool clearThemeProfileId = false, - MapThemePalette? themeOverridePalette, - bool clearThemeOverridePalette = false, - }) { - return StrategyData( - id: id ?? this.id, - name: name ?? this.name, - versionNumber: versionNumber ?? this.versionNumber, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - drawingData: drawingData ?? this.drawingData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - agentData: agentData ?? this.agentData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - abilityData: abilityData ?? this.abilityData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - textData: textData ?? this.textData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - imageData: imageData ?? this.imageData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - utilityData: utilityData ?? this.utilityData, - pages: pages ?? this.pages, - mapData: mapData ?? this.mapData, - lastEdited: lastEdited ?? this.lastEdited, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - isAttack: isAttack ?? this.isAttack, - // ignore: deprecated_member_use_from_same_package - strategySettings: strategySettings ?? this.strategySettings, - createdAt: createdAt ?? this.createdAt, - folderID: folderID ?? this.folderID, - themeProfileId: - clearThemeProfileId ? null : (themeProfileId ?? this.themeProfileId), - themeOverridePalette: clearThemeOverridePalette - ? null - : (themeOverridePalette ?? this.themeOverridePalette), - ); - } -} - -class StrategyState { - StrategyState({ - required this.isSaved, - required this.stratName, - required this.id, - required this.storageDirectory, - this.activePageId, - }); - - final bool isSaved; - final String? stratName; - final String id; - final String? storageDirectory; - final String? activePageId; - - StrategyState copyWith({ - bool? isSaved, - String? stratName, - String? id, - String? storageDirectory, - String? activePageId, - bool clearActivePageId = false, - }) { - return StrategyState( - isSaved: isSaved ?? this.isSaved, - stratName: stratName ?? this.stratName, - id: id ?? this.id, - storageDirectory: storageDirectory ?? this.storageDirectory, - activePageId: - clearActivePageId ? null : (activePageId ?? this.activePageId), - ); - } -} +export 'package:icarus/strategy/strategy_models.dart' + show StrategyData, StrategyState; final strategyProvider = NotifierProvider(StrategyProvider.new); -class NewerVersionImportException implements Exception { - const NewerVersionImportException({ - required this.importedVersion, - required this.currentVersion, - }); - - final int importedVersion; - final int currentVersion; - - static const String userMessage = - 'This strategy was created in a newer version of Icarus. ' - 'Please update the app and try again.'; - - @override - String toString() { - return 'NewerVersionImportException(' - 'importedVersion: $importedVersion, ' - 'currentVersion: $currentVersion' - ')'; - } -} - -enum ImportIssueCode { - newerVersion, - invalidStrategy, - invalidArchiveMetadata, - unsupportedFile, - ioError, -} - -class ImportIssue { - const ImportIssue({ - required this.path, - required this.code, - }); - - final String path; - final ImportIssueCode code; -} - -class ImportBatchResult { - const ImportBatchResult({ - required this.strategiesImported, - required this.foldersCreated, - this.themeProfilesImported = 0, - this.globalStateRestored = false, - required this.issues, - }); - - const ImportBatchResult.empty() - : strategiesImported = 0, - foldersCreated = 0, - themeProfilesImported = 0, - globalStateRestored = false, - issues = const []; - - final int strategiesImported; - final int foldersCreated; - final int themeProfilesImported; - final bool globalStateRestored; - final List issues; - - bool get hasImports => - strategiesImported > 0 || - foldersCreated > 0 || - themeProfilesImported > 0 || - globalStateRestored; - - ImportBatchResult merge(ImportBatchResult other) { - return ImportBatchResult( - strategiesImported: strategiesImported + other.strategiesImported, - foldersCreated: foldersCreated + other.foldersCreated, - themeProfilesImported: - themeProfilesImported + other.themeProfilesImported, - globalStateRestored: globalStateRestored || other.globalStateRestored, - issues: [...issues, ...other.issues], - ); - } -} - -class _ImportEntityListing { - const _ImportEntityListing({ - required this.entities, - required this.issues, - }); - - final List entities; - final List issues; -} - -class _ArchiveExportState { - _ArchiveExportState({ - required this.rootDirectory, - }); - - final Directory rootDirectory; - final List folders = []; - final List strategies = []; -} - -class _ManifestImportData { - const _ManifestImportData({ - required this.rootDirectory, - required this.manifestFile, - required this.manifest, - }); - - final Directory rootDirectory; - final File manifestFile; - final ArchiveManifest manifest; -} - -class _GlobalImportResult { - const _GlobalImportResult({ - required this.themeProfilesImported, - required this.globalStateRestored, - required this.profileIdRemap, - }); - - final int themeProfilesImported; - final bool globalStateRestored; - final Map profileIdRemap; -} - -class _ZipManifestData { - const _ZipManifestData({ - required this.manifest, - required this.rootPrefix, - required this.filesByPath, - required this.manifestArchivePath, - }); - - final ArchiveManifest manifest; - final String rootPrefix; - final Map filesByPath; - final String manifestArchivePath; -} - class StrategyProvider extends Notifier { - String? activePageID; - @override StrategyState build() { - return StrategyState( - isSaved: false, - stratName: null, - id: "testID", + _registerPersistenceTrackingListeners(); + ref.listen(authProvider, (previous, next) { + final strategyId = state.strategyId; + if (state.source != StrategySource.cloud || strategyId == null) { + return; + } + final becameReady = + !(previous?.isConvexUserReady ?? false) && next.isConvexUserReady; + if (!becameReady) { + return; + } + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).retryNow( + ignoreBackoff: true, + ), + ); + }); + + return const StrategyState( + strategyId: null, + strategyName: null, + source: null, storageDirectory: null, - activePageId: null, + isOpen: false, ); } @@ -370,791 +90,558 @@ class StrategyProvider extends Notifier { Future? _activeSave; String? _pendingSaveId; + bool _cloudMutationSyncScheduled = false; + bool _cloudStrategyMutationSyncScheduled = false; + int _persistenceTrackingSuspensionCount = 0; + + void _registerPersistenceTrackingListeners() { + _listenForPageBackedState(agentProvider); + _listenForPageBackedState(abilityProvider); + _listenForPageBackedState( + drawingProvider.select((drawing) => drawing.elements), + ); + _listenForPageBackedState(textProvider); + _listenForPageBackedState( + placedImageProvider.select((images) => images.images), + ); + _listenForPageBackedState(utilityProvider); + _listenForPageBackedState( + lineUpProvider.select( + (lineups) => (lineups.origins, lineups.landings, lineups.links), + ), + ); + _listenForPageBackedState(strategySettingsProvider); + _listenForPageBackedState(mapProvider.select((map) => map.isAttack)); - bool get _hasLoadedStrategy => - state.stratName != null && state.id != 'testID'; + _listenForStrategyBackedState(mapProvider.select((map) => map.currentMap)); + _listenForStrategyBackedState(strategyThemeProvider); + } - void _reportImportFailure( - String message, { - Object? error, - StackTrace? stackTrace, - required String source, - }) { - AppErrorReporter.reportError( - message, - error: error, - stackTrace: stackTrace, - source: source, - promptUser: false, - ); + void _listenForPageBackedState(ProviderListenable provider) { + ref.listen(provider, (_, __) { + if (!_shouldTrackPersistedEditorMutation()) { + return; + } + setUnsaved(); + }); + } + + void _listenForStrategyBackedState(ProviderListenable provider) { + ref.listen(provider, (_, __) { + if (!_shouldTrackPersistedEditorMutation()) { + return; + } + _markStrategyBackedStateUnsaved(); + }); + } + + bool _shouldTrackPersistedEditorMutation() { + if (_persistenceTrackingSuspensionCount > 0) { + return false; + } + if (!state.isOpen || state.strategyId == null || state.source == null) { + return false; + } + return !ref.read(strategyPageSessionProvider).isApplyingPage && + _currentStrategyCanEditPages(); + } + + T _withoutPersistenceTracking(T Function() callback) { + _persistenceTrackingSuspensionCount += 1; + try { + return callback(); + } finally { + _persistenceTrackingSuspensionCount -= 1; + } } //Used For Images void setFromState(StrategyState newState) { - state = newState; + final hasIdentity = + newState.strategyId != null || newState.strategyName != null; + state = newState.isOpen || !hasIdentity + ? newState + : newState.copyWith(isOpen: true); + final activePageId = newState.activePageId; + if (activePageId != null) { + final session = ref.read(strategyPageSessionProvider); + ref.read(strategyPageSessionProvider.notifier).setStateForTest( + session.copyWith( + activePageId: activePageId, + availablePageIds: session.availablePageIds.contains(activePageId) + ? session.availablePageIds + : [...session.availablePageIds, activePageId], + ), + ); + } + } + + @Deprecated('Use strategyPageSessionProvider instead.') + set activePageID(String pageId) { + state = state.copyWith(activePageId: pageId); + final session = ref.read(strategyPageSessionProvider); + ref.read(strategyPageSessionProvider.notifier).setStateForTest( + session.copyWith( + activePageId: pageId, + availablePageIds: session.availablePageIds.contains(pageId) + ? session.availablePageIds + : [...session.availablePageIds, pageId], + ), + ); } void cancelPendingSave() { _saveTimer?.cancel(); _saveTimer = null; - _pendingSaveId = null; } void refreshAutosaveScheduling() { cancelPendingSave(); - if (!_hasLoadedStrategy || state.isSaved) { + if (!state.isOpen || !ref.read(strategySaveStateProvider).isDirty) { + return; + } + if (_currentStrategyIsCloud()) { return; } if (!ref.read(appPreferencesProvider).autosaveEnabled) { return; } + _saveTimer = Timer(Settings.autoSaveOffset, () async { - if (state.stratName == null) return; - await _performSave(state.id); + final strategyId = state.strategyId; + if (strategyId == null) { + return; + } + await _performSave(strategyId); }); } - void setUnsaved() async { - state = state.copyWith(isSaved: false); - refreshAutosaveScheduling(); - } - - // For manual “Save now” actions - Future forceSaveNow(String id) async { - cancelPendingSave(); - await _performSave(id); - } - Future flushPendingAutosaveBeforeExit() async { - if (!_hasLoadedStrategy || state.isSaved) { + final strategyId = state.strategyId; + if (strategyId == null || state.strategyName == null) { return true; } - if (!ref.read(appPreferencesProvider).autosaveEnabled) { + final hasTextDrafts = ref.read(textDraftProvider).isNotEmpty; + final saveState = ref.read(strategySaveStateProvider); + if (!saveState.isDirty && !hasTextDrafts) { + return true; + } + + if (!_currentStrategyIsCloud() && + !ref.read(appPreferencesProvider).autosaveEnabled) { return false; } - cancelPendingSave(); - await forceSaveNow(state.id); - return true; + await forceSaveNow(strategyId); + return !ref.read(strategySaveStateProvider).isDirty; } - // Ensures only one save runs at a time. Calls made during that save wait for - // one coalesced follow-up save, so returning means the latest state reached - // Hive. - Future _performSave(String id) { - _pendingSaveId = id; - return _activeSave ??= _drainPendingSaves(); + bool _currentStrategyIsCloud() { + return state.source == StrategySource.cloud; } - Future _drainPendingSaves() async { - try { - while (_pendingSaveId != null) { - final id = _pendingSaveId!; - _pendingSaveId = null; - ref.read(autoSaveProvider.notifier).ping(); // UI: “Saving…” - await saveToHive(id); - } - } finally { - _activeSave = null; + bool _currentStrategyCanEditPages() { + if (!_currentStrategyIsCloud()) { + return true; } + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final role = snapshot?.header.publicId == state.strategyId + ? snapshot?.header.role + : null; + return StrategyCapabilities.fromCloudRole(role).canEditPages; } - Future setStorageDirectory(String strategyID) async { - // final strategyID = state.id; - // Get the system's application support directory. - final directory = await getApplicationSupportDirectory(); + /// Fails loudly instead of reading or writing the on-device library where + /// the platform policy keeps it hidden. Its records stay untouched until + /// local access returns. + void _requireLocalLibrary(String action) { + if (ref.read(platformPolicyProvider).allowsLocalLibrary) return; + throw StateError( + 'Cannot $action a local strategy: the local library is not available ' + 'on this platform.', + ); + } - // Create a custom directory inside the application support directory. + bool _selectedWorkspaceIsCloud() { + return ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; + } - final customDirectory = Directory(path.join(directory.path, strategyID)); + StrategySource _resolveLibraryMutationSource() { + final currentSource = state.source; + if (currentSource != null) { + return currentSource; + } + return _selectedWorkspaceIsCloud() + ? StrategySource.cloud + : StrategySource.local; + } - if (!await customDirectory.exists()) { - await customDirectory.create(recursive: true); + Future _reportCloudUnauthenticated({ + required String source, + required Object error, + required StackTrace stackTrace, + }) async { + if (!isConvexUnauthenticatedError(error)) { + return false; } - return customDirectory; + await ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ); + return true; } - Future clearCurrentStrategy() async { - cancelPendingSave(); - activePageID = null; - ref.read(strategyThemeProvider.notifier).fromStrategy(); - state = StrategyState( - isSaved: true, - stratName: null, - id: "testID", - storageDirectory: state.storageDirectory, - activePageId: null, - ); + Future openStrategy(String strategyID) async { + await openCloudStrategy(strategyID); } - // --- MIGRATION: create a first page from legacy flat fields ---------------- - static Future migrateAllStrategies() async { - final box = Hive.box(HiveBoxNames.strategiesBox); - for (final strat in box.values) { - final legacyMigrated = await migrateLegacyData(strat); - final worldMigrated = migrateToWorld16x9(legacyMigrated); - final abilityScaleMigrated = migrateAbilityScale(worldMigrated); - final squareAoeMigrated = migrateSquareAoeCenter(abilityScaleMigrated); - final customCircleMigrated = - migrateCustomCircleWrapper(squareAoeMigrated); - final lineUpGroupMigrated = migrateLineUpGroups(customCircleMigrated); - final lineUpGraphMigrated = migrateLineUpGraph(lineUpGroupMigrated); - final abilityVisionMigrated = - migrateAbilityVisionCones(lineUpGraphMigrated); - if (abilityVisionMigrated != lineUpGraphMigrated) { - await box.put(abilityVisionMigrated.id, abilityVisionMigrated); - } else if (lineUpGraphMigrated != lineUpGroupMigrated) { - await box.put(lineUpGraphMigrated.id, lineUpGraphMigrated); - } else if (lineUpGroupMigrated != customCircleMigrated) { - await box.put(lineUpGroupMigrated.id, lineUpGroupMigrated); - } else if (customCircleMigrated != squareAoeMigrated) { - await box.put(customCircleMigrated.id, customCircleMigrated); - } else if (squareAoeMigrated != abilityScaleMigrated) { - await box.put(squareAoeMigrated.id, squareAoeMigrated); - } else if (abilityScaleMigrated != worldMigrated) { - await box.put(abilityScaleMigrated.id, abilityScaleMigrated); - } else if (worldMigrated != legacyMigrated) { - await box.put(worldMigrated.id, worldMigrated); - } else if (legacyMigrated != strat) { - await box.put(legacyMigrated.id, legacyMigrated); - } + Future openCloudStrategy(String strategyID) async { + cancelPendingSave(); + ref.read(strategySaveStateProvider.notifier).reset(); + + await ref + .read(remoteEditorSnapshotProvider.notifier) + .openStrategy(strategyID); + final snapshotState = ref.read(remoteEditorSnapshotProvider); + final snapshot = snapshotState.valueOrNull; + if (snapshot == null) { + // Returning silently here used to leave the editor on an eternal + // skeleton with no feedback. Throw so callers can surface the failure + // and back out. + throw StateError( + 'Cloud strategy snapshot unavailable for $strategyID' + '${snapshotState.hasError ? ': ${snapshotState.error}' : ''}', + ); } - } - static StrategyData migrateAbilityScale(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= AbilityScaleMigration.version) { - return strat; - } + final storageDirectory = kIsWeb + ? null + : (await setStorageDirectory(snapshot.header.publicId)).path; + state = state.copyWith( + strategyId: snapshot.header.publicId, + strategyName: snapshot.header.name, + source: StrategySource.cloud, + storageDirectory: storageDirectory, + isOpen: true, + ); - final migratedPages = AbilityScaleMigration.migratePages( - pages: strat.pages, - map: strat.mapData, + await ref.read(strategyPageSessionProvider.notifier).initializeForStrategy( + strategyId: snapshot.header.publicId, + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).reconcilePageMedia( + strategyPublicId: snapshot.header.publicId, + placedImages: ref.read(placedImageProvider).images, + assetsById: snapshot.assetsById, + ), + ); + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy( + snapshot.header.publicId, + ), ); + } - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; - }); + Future switchPage(String pageID) async { + await ref.read(strategyPageSessionProvider.notifier).setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); + } - if (!hasPageChanged && !force) { - return strat; + Future enqueueOps( + List ops, { + bool flushImmediately = false, + }) async { + if (!_currentStrategyIsCloud() || + !_currentStrategyCanEditPages() || + ops.isEmpty) { + return; } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - } - - static StrategyData migrateSquareAoeCenter(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= SquareAoeCenterMigration.version) { - return strat; + ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: flushImmediately); + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; } - final migratedPages = SquareAoeCenterMigration.migratePages( - pages: strat.pages, - ); + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + } - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; - }); + Future _enqueueCloudPageDescriptorOp(StrategyOp op) async { + await enqueueOps([op]); + final queue = ref.read(strategyOpQueueProvider.notifier); + await queue.flushNow(); + return ref + .read(strategyOpQueueProvider) + .lastAcks + .where((ack) => ack.opId == op.opId) + .firstOrNull; + } - if (!hasPageChanged && !force) { - return strat; + Future notifyCloudMutation({bool flushImmediately = false}) async { + _cloudMutationSyncScheduled = false; + if (!_currentStrategyIsCloud() || !_currentStrategyCanEditPages()) { + return; } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + await ref + .read(strategyPageSessionProvider.notifier) + .flushCurrentPage(flushImmediately: flushImmediately); } - static StrategyData migrateCustomCircleWrapper(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= CustomCircleWrapperMigration.version) { - return strat; + Future notifyCloudStrategyMutation({ + bool flushImmediately = false, + }) async { + _cloudStrategyMutationSyncScheduled = false; + if (!_currentStrategyIsCloud() || !_currentStrategyCanEditPages()) { + return; } - final migratedPages = CustomCircleWrapperMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ); - - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return entry.value != strat.pages[index]; - }); + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); - if (!hasPageChanged && !force) { - return strat; + final desiredOp = _buildDesiredStrategySyncOp(); + ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + entityKey: const EntitySyncKey.strategy(), + desiredOp: desiredOp, + flushImmediately: flushImmediately, + ); + if (flushImmediately) { + await ref.read(strategyOpQueueProvider.notifier).flushNow(); } + } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + void _scheduleCloudMutationSync() { + if (_cloudMutationSyncScheduled) { + return; + } + _cloudMutationSyncScheduled = true; + scheduleMicrotask(() async { + if (!_cloudMutationSyncScheduled) { + return; + } + await notifyCloudMutation(flushImmediately: false); + }); } - static StrategyData migrateToCurrentVersion( - StrategyData strat, { - bool forceAbilityScale = false, - }) { - final originalVersion = strat.versionNumber; - final needsCanonicalCoordinatesMigration = - strat.versionNumber < CanonicalCoordinatesMigration.version; - final needsAbilityVisionMigration = - strat.versionNumber < AbilityVisionConeMigration.version; - final needsPageNameProvenanceMigration = - strat.versionNumber < PageNameProvenanceMigration.version; - final worldMigrated = migrateToWorld16x9(strat); - final abilityScaleMigrated = migrateAbilityScale( - worldMigrated, - force: - forceAbilityScale || originalVersion < AbilityScaleMigration.version, - ); - final squareAoeMigrated = migrateSquareAoeCenter( - abilityScaleMigrated, - force: originalVersion < SquareAoeCenterMigration.version, - ); - final customCircleMigrated = migrateCustomCircleWrapper( - squareAoeMigrated, - force: originalVersion < CustomCircleWrapperMigration.version, - ); - final lineUpGroupMigrated = migrateLineUpGroups( - customCircleMigrated, - force: originalVersion < LineUpGroupMigration.version, - ); - final lineUpGraphMigrated = migrateLineUpGraph( - lineUpGroupMigrated, - force: originalVersion < LineUpGraphMigration.version, - ); - final abilityVisionMigrated = migrateAbilityVisionCones( - lineUpGraphMigrated, - force: needsAbilityVisionMigration, - ); - final pageNameMigrated = migratePageNameProvenance( - abilityVisionMigrated, - force: needsPageNameProvenanceMigration, - ); - final canonicalMigrated = migrateCanonicalCoordinates( - pageNameMigrated, - force: needsCanonicalCoordinatesMigration, - ); - final sunsetMigrated = migrateSunsetScale( - canonicalMigrated, - force: originalVersion < SunsetScaleMigration.version, - ); - final paranoiaMigrated = migrateParanoiaRange( - sunsetMigrated, - force: originalVersion < ParanoiaRangeMigration.version, - ); - final migrated = AgentWeaponMigration.migrate(paranoiaMigrated); - // Releases without a schema change still finish at the current version. - // Never downgrade a strategy written by a newer release. - return migrated.versionNumber < Settings.versionNumber - ? migrated.copyWith(versionNumber: Settings.versionNumber) - : migrated; + void consumeScheduledCloudPageSync() { + _cloudMutationSyncScheduled = false; } - static StrategyData migrateSunsetScale( - StrategyData strat, { - bool force = false, - }) { - if (!force && strat.versionNumber >= SunsetScaleMigration.version) { - return strat; - } - return strat.copyWith( - pages: SunsetScaleMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ), - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + void consumeScheduledCloudStrategySync() { + _cloudStrategyMutationSyncScheduled = false; } - static StrategyData migrateParanoiaRange( - StrategyData strat, { - bool force = false, - }) { - if (!force && strat.versionNumber >= ParanoiaRangeMigration.version) { - return strat; + void _scheduleCloudStrategySync() { + if (_cloudStrategyMutationSyncScheduled) { + return; } - final pages = ParanoiaRangeMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ); - if (identical(pages, strat.pages)) return strat; - return strat.copyWith( - pages: pages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + _cloudStrategyMutationSyncScheduled = true; + scheduleMicrotask(() async { + if (!_cloudStrategyMutationSyncScheduled) { + return; + } + await notifyCloudStrategyMutation(flushImmediately: false); + }); } - static StrategyData migrateCanonicalCoordinates(StrategyData strat, - {bool force = false}) { - if (!force && - strat.versionNumber >= CanonicalCoordinatesMigration.version) { - return strat; + void _markStrategyBackedStateUnsaved() { + if (!state.isOpen || state.strategyId == null || state.source == null) { + return; + } + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; + } + if (!_currentStrategyCanEditPages()) { + return; } - return strat.copyWith( - pages: CanonicalCoordinatesMigration.migratePages( - pages: strat.pages, - map: strat.mapData, - ), - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - } - - static StrategyData migrateAbilityVisionCones(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= AbilityVisionConeMigration.version) { - return strat; + if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + _scheduleCloudStrategySync(); + return; } - final migratedPages = AbilityVisionConeMigration.migratePages( - pages: strat.pages, - ); - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + ref.read(strategySaveStateProvider.notifier).markDirty(); + refreshAutosaveScheduling(); } - static StrategyData migratePageNameProvenance(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= PageNameProvenanceMigration.version) { - return strat; + StrategyOp? _buildDesiredStrategySyncOp() { + final strategyId = state.strategyId; + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (strategyId == null || + snapshot == null || + snapshot.header.publicId != strategyId) { + return null; } - final migratedPages = - PageNameProvenanceMigration.migratePages(pages: strat.pages); + final strategyTheme = ref.read(strategyThemeProvider); + final localMapData = Maps.mapNames[ref.read(mapProvider).currentMap] ?? + snapshot.header.mapData; + final localThemeProfileId = strategyTheme.profileId; + final localThemeOverridePalette = strategyTheme.overridePalette?.toJson(); + + final matchesRemote = snapshot.header.mapData == localMapData && + snapshot.header.themeProfileId == localThemeProfileId && + cloudJsonEquivalent( + snapshot.header.themeOverridePalette, + localThemeOverridePalette, + ); + if (matchesRemote) { + return null; + } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), + return StrategyPatchOp( + opId: const Uuid().v4(), + expectedStrategyRevision: snapshot.header.revision, + payload: { + 'mapData': localMapData, + if (localThemeProfileId != null) 'themeProfileId': localThemeProfileId, + if (localThemeProfileId == null) 'clearThemeProfileId': true, + if (localThemeOverridePalette != null) + 'themeOverridePalette': localThemeOverridePalette, + if (localThemeOverridePalette == null) + 'clearThemeOverridePalette': true, + }, ); } - static StrategyData migrateLineUpGraph(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= LineUpGraphMigration.version) { - return strat; + void setUnsaved() { + if (!state.isOpen || state.strategyId == null || state.source == null) { + return; } - - final migratedPages = LineUpGraphMigration.migratePages(pages: strat.pages); - final hasPageChanged = migratedPages.asMap().entries.any( - (entry) => !identical(entry.value, strat.pages[entry.key]), - ); - if (!hasPageChanged && !force) { - return strat; + if (ref.read(strategyPageSessionProvider).isApplyingPage) { + return; } - - return strat.copyWith( - pages: hasPageChanged ? migratedPages : strat.pages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - } - - static StrategyData migrateLineUpGroups(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= LineUpGroupMigration.version) { - return strat; + if (!_currentStrategyCanEditPages()) { + return; } - final migratedPages = LineUpGroupMigration.migratePages(pages: strat.pages); - final hasPageChanged = migratedPages.length == strat.pages.length && - migratedPages.asMap().entries.any((entry) { - final index = entry.key; - return !identical(entry.value, strat.pages[index]); - }); + state = state.copyWith(isSaved: false); - if (!hasPageChanged && !force) { - return strat; + if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true) + ..setCloudSyncError(null); + _scheduleCloudMutationSync(); + return; } - return strat.copyWith( - pages: migratedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); + ref.read(strategySaveStateProvider.notifier).markDirty(); + refreshAutosaveScheduling(); } - static Future migrateLegacyData(StrategyData strat) async { - // Already migrated - if (strat.pages.isNotEmpty) { - return migrateToCurrentVersion(strat); - } - if (strat.versionNumber > 15) { - return migrateToCurrentVersion(strat); + Future forceSaveNow(String id) async { + if (!_currentStrategyCanEditPages()) { + return; } - final originalVersion = strat.versionNumber; - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - final abilityData = [...strat.abilityData]; - if (strat.versionNumber < 7) { - for (final a in abilityData) { - if (a.data.abilityData! is SquareAbility) { - a.position = a.position.translate(0, -7.5); - } - } + cancelPendingSave(); + if (_currentStrategyIsCloud()) { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true); } - - final firstPage = StrategyPage( - id: const Uuid().v4(), - name: "Page 1", - isAutoNamed: true, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - drawingData: [...strat.drawingData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - agentData: [...strat.agentData], - abilityData: abilityData, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - textData: [...strat.textData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - imageData: [...strat.imageData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - utilityData: [...strat.utilityData], - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - isAttack: strat.isAttack, - // ignore: deprecated_member_use_from_same_package - settings: strat.strategySettings, - sortIndex: 0, - ); - - final updated = strat.copyWith( - pages: [firstPage], - agentData: [], - abilityData: [], - drawingData: [], - utilityData: [], - textData: [], - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), - ); - - final worldMigrated = migrateToWorld16x9(updated, - force: originalVersion < Settings.versionNumber); - final abilityScaleMigrated = migrateAbilityScale( - worldMigrated, - force: originalVersion < AbilityScaleMigration.version, - ); - final squareAoeMigrated = migrateSquareAoeCenter( - abilityScaleMigrated, - force: originalVersion < SquareAoeCenterMigration.version, - ); - final customCircleMigrated = migrateCustomCircleWrapper( - squareAoeMigrated, - force: originalVersion < CustomCircleWrapperMigration.version, - ); - final lineUpGroupMigrated = migrateLineUpGroups( - customCircleMigrated, - force: originalVersion < LineUpGroupMigration.version, - ); - final lineUpGraphMigrated = migrateLineUpGraph( - lineUpGroupMigrated, - force: originalVersion < LineUpGraphMigration.version, - ); - final abilityVisionMigrated = migrateAbilityVisionCones( - lineUpGraphMigrated, - force: originalVersion < AbilityVisionConeMigration.version, - ); - final pageNameMigrated = migratePageNameProvenance( - abilityVisionMigrated, - force: originalVersion < PageNameProvenanceMigration.version, - ); - final canonicalMigrated = migrateCanonicalCoordinates( - pageNameMigrated, - force: originalVersion < CanonicalCoordinatesMigration.version, - ); - final sunsetMigrated = migrateSunsetScale(canonicalMigrated, - force: originalVersion < SunsetScaleMigration.version); - return migrateParanoiaRange(sunsetMigrated, - force: originalVersion < ParanoiaRangeMigration.version); + await _performSave(id); } - static StrategyData migrateToWorld16x9(StrategyData strat, - {bool force = false}) { - if (!force && strat.versionNumber >= 38) return strat; - - const double normalizedHeight = 1000.0; - const double mapAspectRatio = 1.24; - const double worldAspectRatio = 16 / 9; - const mapWidth = normalizedHeight * mapAspectRatio; - const worldWidth = normalizedHeight * worldAspectRatio; - const padding = (worldWidth - mapWidth) / 2; - - Offset shift(Offset offset) => offset.translate(padding, 0); - - List shiftAgentNodes(List agents) { - return [ - for (final agent in agents) - switch (agent) { - PlacedAgent() => agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - PlacedViewConeAgent() => - agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - PlacedCircleAgent() => - agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - }, - ]; - } - - List shiftAbilities(List abilities) { - return [ - for (final ability in abilities) - ability.copyWith(position: shift(ability.position)) - ..isDeleted = ability.isDeleted - ]; - } + // Calls made during an active save wait for one coalesced follow-up save. + Future _performSave(String id) { + _pendingSaveId = id; + return _activeSave ??= _drainPendingSaves(); + } - List shiftTexts(List texts) { - return [ - for (final text in texts) - text.copyWith( - position: shift(text.position), - ) - ]; + Future _drainPendingSaves() async { + ref.read(strategySaveStateProvider.notifier).markSaving(true); + try { + while (_pendingSaveId != null) { + final id = _pendingSaveId!; + _pendingSaveId = null; + ref.read(autoSaveProvider.notifier).ping(); + if (_currentStrategyIsCloud()) { + await notifyCloudStrategyMutation(flushImmediately: true); + await ref + .read(strategyPageSessionProvider.notifier) + .flushCurrentPage(flushImmediately: true); + } else { + await saveToHive(id); + } + } + } finally { + ref.read(strategySaveStateProvider.notifier).markSaving(false); + _activeSave = null; } + } - List shiftImages(List images) { - return [ - for (final image in images) - image.copyWith(position: shift(image.position)) - ..isDeleted = image.isDeleted - ]; - } + Future setStorageDirectory(String strategyID) async { + // final strategyID = state.id; + // Get the system's application support directory. + final directory = await getApplicationSupportDirectory(); - List shiftUtilities(List utilities) { - return [ - for (final utility in utilities) - PlacedUtility( - type: utility.type, - position: shift(utility.position), - id: utility.id, - angle: utility.angle, - visionElevation: utility.visionElevation, - customDiameter: utility.customDiameter, - customWidth: utility.customWidth, - customLength: utility.customLength, - customColorValue: utility.customColorValue, - customOpacityPercent: utility.customOpacityPercent, - ) - ..rotation = utility.rotation - ..length = utility.length - ..isDeleted = utility.isDeleted - ]; - } + // Create a custom directory inside the application support directory. - LineUpGraph shiftLineUpGraph(LineUpGraph graph) { - return graph.mapNodes( - agent: (agent) => agent.copyWith(position: shift(agent.position)) - ..isDeleted = agent.isDeleted, - ability: (ability) => - ability.copyWith(position: shift(ability.position)) - ..isDeleted = ability.isDeleted, - ); - } + final customDirectory = Directory(path.join(directory.path, strategyID)); - BoundingBox? shiftBoundingBox(BoundingBox? boundingBox) { - if (boundingBox == null) return null; - return BoundingBox( - min: shift(boundingBox.min), - max: shift(boundingBox.max), - ); + if (!await customDirectory.exists()) { + await customDirectory.create(recursive: true); } - List shiftDrawings(List drawings) { - return drawings - .map((element) { - if (element is Line) { - return Line( - lineStart: shift(element.lineStart), - lineEnd: shift(element.lineEnd), - colorValue: element.colorValue, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - showTraversalTime: element.showTraversalTime, - traversalSpeedProfile: element.traversalSpeedProfile, - ); - } - if (element is FreeDrawing) { - final shiftedPoints = - element.listOfPoints.map(shift).toList(growable: false); - - return FreeDrawing( - listOfPoints: shiftedPoints, - colorValue: element.colorValue, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - showTraversalTime: element.showTraversalTime, - traversalSpeedProfile: element.traversalSpeedProfile, - ); - } - if (element is RectangleDrawing) { - return RectangleDrawing( - start: shift(element.start), - end: shift(element.end), - colorValue: element.colorValue, - thickness: element.thickness, - boundingBox: shiftBoundingBox(element.boundingBox), - isDotted: element.isDotted, - hasArrow: element.hasArrow, - id: element.id, - ); - } - return element; - }) - .cast() - .toList(growable: false); - } + return customDirectory; + } - final updatedPages = strat.pages - .map((page) => page.copyWith( - sortIndex: page.sortIndex, - name: page.name, - id: page.id, - agentData: shiftAgentNodes(page.agentData), - abilityData: shiftAbilities(page.abilityData), - textData: shiftTexts(page.textData), - imageData: shiftImages(page.imageData), - utilityData: shiftUtilities(page.utilityData), - drawingData: shiftDrawings(page.drawingData), - lineUpGraph: shiftLineUpGraph(page.lineUpGraph), - )) - .toList(growable: false); - - final migrated = strat.copyWith( - pages: updatedPages, - versionNumber: Settings.versionNumber, - lastEdited: DateTime.now(), + Future clearCurrentStrategy() async { + cancelPendingSave(); + ref.read(strategyThemeProvider.notifier).fromStrategy(); + ref.read(strategySaveStateProvider.notifier).reset(); + ref.read(strategyPageSessionProvider.notifier).reset(); + state = StrategyState( + strategyId: null, + strategyName: null, + source: null, + storageDirectory: state.storageDirectory, + isOpen: false, + ); + ref.read(remoteEditorSnapshotProvider.notifier).clear(); + unawaited( + ref.read(cloudMediaUploadQueueProvider.notifier).setActiveStrategy(null), ); - - return migrated; } // Switch active page: flush old page first, then hydrate new Future setActivePage(String pageID) async { - if (pageID == activePageID) return; - - // Flush current before switching - await _syncCurrentPageToHive(); - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null) return; - - final page = doc.pages.firstWhere( - (p) => p.id == pageID, - orElse: () => doc.pages.first, - ); - - activePageID = page.id; - state = state.copyWith(activePageId: page.id); - - ref.read(actionProvider.notifier).resetActionState(); - final migrated = migrateToCurrentVersion(doc); - final migratedPage = migrated.pages.firstWhere( - (p) => p.id == page.id, - orElse: () => migrated.pages.first, - ); - if (migrated != doc) { - await box.put(migrated.id, migrated); - } - - ref.read(agentProvider.notifier).fromHive(migratedPage.agentData); - ref.read(abilityProvider.notifier).fromHive(migratedPage.abilityData); - ref.read(drawingProvider.notifier).fromHive(migratedPage.drawingData); - ref.read(textProvider.notifier).fromHive(migratedPage.textData); - ref.read(placedImageProvider.notifier).fromHive(migratedPage.imageData); - ref.read(utilityProvider.notifier).fromHive(migratedPage.utilityData); - ref.read(mapProvider.notifier).setAttack(migratedPage.isAttack); - ref.read(strategySettingsProvider.notifier).fromHive(migratedPage.settings); - ref.read(strategyThemeProvider.notifier).fromStrategy( - profileId: migrated.themeProfileId ?? - MapThemeProfilesProvider.immutableDefaultProfileId, - overridePalette: migrated.themeOverridePalette, - ); - ref.read(lineUpProvider.notifier).fromHive(migratedPage.lineUpGraph); - - // Defer path rebuild until next frame (layout complete) - WidgetsBinding.instance.addPostFrameCallback((_) { - ref - .read(drawingProvider.notifier) - .rebuildAllPaths(CoordinateSystem.instance); - }); + await ref.read(strategyPageSessionProvider.notifier).setActivePage(pageID); + state = state.copyWith(activePageId: pageID); } Future backwardPage() async { - if (activePageID == null) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - // Order pages by their sortIndex to find the "leading" (next) page. - final pages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - - final currentIndex = pages.indexWhere((p) => p.id == activePageID); - if (currentIndex == -1) return; - int nextIndex = currentIndex - 1; - if (nextIndex < 0) - nextIndex = pages.length - 1; // No forward page available. - - final nextPage = pages[nextIndex]; - await setActivePageAnimated( - nextPage.id, - direction: PageTransitionDirection.backward, - ); + await ref + .read(strategyPageSessionProvider.notifier) + .switchRelativePage(PageSwitchDirection.previous); } Future forwardPage() async { - if (activePageID == null) return; - - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - // Order pages by their sortIndex to find the "leading" (next) page. - final pages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - - final currentIndex = pages.indexWhere((p) => p.id == activePageID); - if (currentIndex == -1) return; - - int nextIndex = currentIndex + 1; - if (nextIndex >= pages.length) nextIndex = 0; // No forward page available. - - final nextPage = pages[nextIndex]; - await setActivePageAnimated( - nextPage.id, - direction: PageTransitionDirection.forward, - ); + await ref + .read(strategyPageSessionProvider.notifier) + .switchRelativePage(PageSwitchDirection.next); } static List reindexPagesAfterStructuralChange( @@ -1172,17 +659,44 @@ class StrategyProvider extends Notifier { } Future reorderPage(int oldIndex, int newIndex) async { + if (!_currentStrategyCanEditPages()) return; if (oldIndex == newIndex) return; + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) return; + final ordered = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + if (oldIndex < 0 || + oldIndex >= ordered.length || + newIndex < 0 || + newIndex > ordered.length) { + return; + } + + final moved = ordered.removeAt(oldIndex); + final targetIndex = newIndex.clamp(0, ordered.length); + ordered.insert(targetIndex, moved); + + final ack = await _enqueueCloudPageDescriptorOp(PageReorderOp( + opId: const Uuid().v4(), + pagePublicId: moved.publicId, + sortIndex: targetIndex, + expectedStrategyRevision: snapshot.header.revision, + )); + if (ack != null) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + } + return; + } + final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) return; - // `oldIndex`/`newIndex` are list positions from the UI (ReorderableListView), - // not sortIndex values. We move the page and then reindex to keep a dense - // 0..N-1 ordering. - final ordered = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + final ordered = [...strat.pages]..sortBySortIndex((item) => item.sortIndex); if (oldIndex < 0 || oldIndex >= ordered.length || @@ -1192,7 +706,8 @@ class StrategyProvider extends Notifier { } final moved = ordered.removeAt(oldIndex); - ordered.insert(newIndex, moved); + final targetIndex = newIndex.clamp(0, ordered.length); + ordered.insert(targetIndex, moved); final reindexed = reindexPagesAfterStructuralChange(ordered); @@ -1201,180 +716,62 @@ class StrategyProvider extends Notifier { await box.put(updated.id, updated); } - PageTransitionDirection _resolveDirectionForPage( - String pageID, List orderedPages) { - if (activePageID == null) return PageTransitionDirection.forward; - - final currentIndex = orderedPages.indexWhere((p) => p.id == activePageID); - final targetIndex = orderedPages.indexWhere((p) => p.id == pageID); - if (currentIndex < 0 || targetIndex < 0) { - return PageTransitionDirection.forward; - } - - final length = orderedPages.length; - final forwardSteps = (targetIndex - currentIndex + length) % length; - final backwardSteps = (currentIndex - targetIndex + length) % length; - return forwardSteps <= backwardSteps - ? PageTransitionDirection.forward - : PageTransitionDirection.backward; - } - // Add these inside StrategyProvider Future setActivePageAnimated(String pageID, {PageTransitionDirection? direction, Duration duration = kPageTransitionDuration}) async { - if (pageID == activePageID) return; - - final transitionState = ref.read(transitionProvider); - final transitionNotifier = ref.read(transitionProvider.notifier); - if (transitionState.active || - transitionState.phase == PageTransitionPhase.preparing) { - transitionNotifier.complete(); - } + await ref.read(strategyPageSessionProvider.notifier).setActivePageAnimated( + pageID, + direction: direction ?? PageTransitionDirection.forward, + duration: duration, + ); + state = state.copyWith(activePageId: pageID); + } - final box = Hive.box(HiveBoxNames.strategiesBox); - final doc = box.get(state.id); - if (doc == null || doc.pages.isEmpty) return; - - final orderedPages = [...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final resolvedDirection = - direction ?? _resolveDirectionForPage(pageID, orderedPages); - final sourcePageId = activePageID; - final targetPageId = pageID; - final startSettings = ref.read(strategySettingsProvider); - - final targetPage = orderedPages.firstWhere( - (p) => p.id == pageID, - orElse: () => orderedPages.first, - ); - final fadeInDrawings = TransitionPlanner.drawingsChanged( - ref.read(drawingProvider).elements, - targetPage.drawingData, + static StrategyData migrateToCurrentVersion( + StrategyData strategy, { + bool forceAbilityScale = false, + }) { + return StrategyMigrator.migrateToCurrentVersion( + strategy, + forceAbilityScale: forceAbilityScale, ); - - final prev = _snapshotAllPlaced(); - transitionNotifier.prepare(prev.values.toList(), - direction: resolvedDirection, - startAgentSize: startSettings.agentSize, - startAbilitySize: startSettings.abilitySize, - sourcePageId: sourcePageId, - targetPageId: targetPageId, - fadeInDrawings: fadeInDrawings); - - // Load target page (hydrates providers) - await setActivePage(pageID); - // After layout, snapshot next and start transition - WidgetsBinding.instance.addPostFrameCallback((_) async { - final preliminaryNext = _snapshotAllPlaced(); - final preliminaryEntries = _diffToTransitions(prev, preliminaryNext); - if (preliminaryEntries.isEmpty && !fadeInDrawings) { - transitionNotifier.complete(); - return; - } - - final needsAgentRouting = preliminaryEntries.any( - (entry) => - entry.kind == TransitionKind.move && - entry.visualWidget is PlacedAgentNode, - ); - - // Agent routes load the small movement mesh independently of sightlines. - // Unchanged agents, abilities and drawings need no route initialization. - VisionGeometryMap? transitionGeometry; - NavigationGeometryMap? transitionNavigation; - final map = ref.read(mapProvider).currentMap; - final requireNavigation = ref.read(worldGeometryEnabledProvider(map)); - if (needsAgentRouting) { - try { - if (requireNavigation) { - transitionNavigation = - await ref.read(navigationGeometryProvider(map).future); - } else { - transitionGeometry = - await ref.read(viewConeGeometryProvider(map).future); - } - } on Object { - // The provider reports the failure. Required native routes hold at - // their sources, so changing pages cannot invent movement through walls. - } - } - - // A geometry load may yield long enough for another navigation request - // to supersede this one. Do not start a stale transition afterward. - final currentTransition = ref.read(transitionProvider); - if (currentTransition.phase != PageTransitionPhase.preparing || - currentTransition.sourcePageId != sourcePageId || - currentTransition.targetPageId != targetPageId) { - return; - } - - // The preliminary entries only decide whether geometry is needed. Build - // a distinct final snapshot after the possible await so stale entries - // can never reach start(), even when page IDs did not change. - final next = _snapshotAllPlaced(); - final entries = _diffToTransitions(prev, next); - if (entries.isEmpty && !fadeInDrawings) { - transitionNotifier.complete(); - return; - } - - final endSettings = ref.read(strategySettingsProvider); - final agentPaths = AgentTransitionPathPlanner.plan( - entries: entries, - geometry: transitionGeometry, - navigation: transitionNavigation, - requireNavigation: requireNavigation, - startAgentSize: startSettings.agentSize, - endAgentSize: endSettings.agentSize, - coordinateSystem: CoordinateSystem.instance, - ); - transitionNotifier.start( - entries, - duration: duration, - direction: resolvedDirection, - startAgentSize: startSettings.agentSize, - endAgentSize: endSettings.agentSize, - startAbilitySize: startSettings.abilitySize, - endAbilitySize: endSettings.abilitySize, - sourcePageId: sourcePageId, - targetPageId: targetPageId, - agentPaths: agentPaths, - fadeInDrawings: fadeInDrawings, - ); - }); } - Map _snapshotAllPlaced() { - final map = {}; - for (final a in ref.read(agentProvider)) map[a.id] = a; - for (final ab in ref.read(abilityProvider)) map[ab.id] = ab; - for (final t in ref.read(textProvider)) map[t.id] = t; - for (final img in ref.read(placedImageProvider).images) map[img.id] = img; - for (final u in ref.read(utilityProvider)) map[u.id] = u; - return map; + static StrategyData migrateAbilityVisionCones( + StrategyData strategy, { + bool force = false, + }) { + return StrategyMigrator.migrateAbilityVisionCones( + strategy, + force: force, + ); } - List _diffToTransitions( - Map prev, - Map next, - ) => - TransitionPlanner.diff(prev, next); + static String sanitizeFileName(String input) { + final sanitized = input.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); + return sanitized.isEmpty ? 'untitled' : sanitized; + } List copyDirectionsForPlacedWidget( String widgetId, ) { - if (widgetId.isEmpty || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) + if (_currentStrategyIsCloud() || + widgetId.isEmpty || + !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) { return const []; + } + final strategyId = state.strategyId; + if (strategyId == null) return const []; final strat = Hive.box(HiveBoxNames.strategiesBox).get( - state.id, + strategyId, ); if (strat == null || strat.pages.length < 2) return const []; final orderedPages = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final currentPageId = activePageID ?? state.activePageId; + ..sortBySortIndex((item) => item.sortIndex); + final currentPageId = ref.read(strategyPageSessionProvider).activePageId; final currentIndex = orderedPages.indexWhere( (page) => page.id == currentPageId, ); @@ -1396,18 +793,23 @@ class StrategyProvider extends Notifier { required String widgetId, required PageTransitionDirection direction, }) async { - if (widgetId.isEmpty || !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) + if (_currentStrategyIsCloud() || + widgetId.isEmpty || + !Hive.isBoxOpen(HiveBoxNames.strategiesBox)) { return false; + } await _syncCurrentPageToHive(); + final strategyId = state.strategyId; + if (strategyId == null) return false; final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); + final strat = box.get(strategyId); if (strat == null || strat.pages.length < 2) return false; final orderedPages = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final currentPageId = activePageID ?? state.activePageId; + ..sortBySortIndex((item) => item.sortIndex); + final currentPageId = ref.read(strategyPageSessionProvider).activePageId; final currentIndex = orderedPages.indexWhere( (page) => page.id == currentPageId, ); @@ -1507,17 +909,57 @@ class StrategyProvider extends Notifier { } Future addPage([String? name]) async { + if (!_currentStrategyCanEditPages()) return; + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null) return; + final pages = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + final pageID = const Uuid().v4(); + final activePageId = ref.read(strategyPageSessionProvider).activePageId; + final activeIndex = pages.indexWhere( + (page) => page.publicId == activePageId, + ); + final sourceIndex = activeIndex >= 0 ? activeIndex : pages.length - 1; + final nextIndex = sourceIndex + 1; + final isAutoNamed = name == null; + final ack = await _enqueueCloudPageDescriptorOp(PageAddOp( + opId: const Uuid().v4(), + pagePublicId: pageID, + payload: { + 'name': name ?? 'Page ${nextIndex + 1}', + 'isAutoNamed': isAutoNamed, + 'isAttack': pages.isNotEmpty ? pages[sourceIndex].isAttack : true, + 'settings': ref.read(strategySettingsProvider).toJson(), + }, + sortIndex: nextIndex, + expectedStrategyRevision: snapshot.header.revision, + )); + if (ack?.isAck ?? false) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + pageID, + direction: PageTransitionDirection.forward, + ); + } + return; + } + final box = Hive.box(HiveBoxNames.strategiesBox); // Flush current page so its edits are not lost await _syncCurrentPageToHive(); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) return; final orderedPages = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final currentPageId = activePageID ?? state.activePageId; + ..sortBySortIndex((item) => item.sortIndex); + final currentPageId = ref.read(strategyPageSessionProvider).activePageId; final currentIndex = orderedPages.indexWhere( (page) => page.id == currentPageId, ); @@ -1525,7 +967,7 @@ class StrategyProvider extends Notifier { currentIndex >= 0 ? currentIndex : orderedPages.length - 1; final insertionIndex = sourceIndex + 1; final isAutoNamed = name == null; - name ??= "Page ${insertionIndex + 1}"; + name ??= 'Page ${insertionIndex + 1}'; final newPage = orderedPages[sourceIndex].copyWith( id: const Uuid().v4(), name: name, @@ -1545,2380 +987,425 @@ class StrategyProvider extends Notifier { await setActivePageAnimated(newPage.id); } - Future loadFromHive(String id) async { - cancelPendingSave(); - final newStrat = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((StrategyData strategy) { - return strategy.id == id; - }).firstOrNull; - - if (newStrat == null) { + Future renamePage(String pageId, String newName) async { + if (!_currentStrategyCanEditPages()) return; + final trimmed = newName.trim(); + if (trimmed.isEmpty) { return; } - ref.read(actionProvider.notifier).resetActionState(); - List pageImageData = []; - for (final page in newStrat.pages) { - pageImageData.addAll(page.imageData); - } - if (!kIsWeb) { - List allImageIds = []; - for (final page in newStrat.pages) { - allImageIds.addAll(page.imageData.map((image) => image.id)); - for (final link in page.lineUpLinks) { - allImageIds.addAll(link.images.map((image) => image.id)); - } + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + final page = snapshot?.pages + .where((candidate) => candidate.publicId == pageId) + .firstOrNull; + if (page == null) return; + final ack = await _enqueueCloudPageDescriptorOp(PagePatchOp( + opId: const Uuid().v4(), + pagePublicId: pageId, + payload: {'name': trimmed, 'isAutoNamed': false}, + expectedPageRevision: page.revision, + )); + if (ack != null) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); } - await ref - .read(placedImageProvider.notifier) - .deleteUnusedImages(newStrat.id, allImageIds); - } - - // We clear previous data to avoid artifacts when loading a new strategy - final migratedStrategy = migrateToCurrentVersion(newStrat); - final page = migratedStrategy.pages.first; - - if (migratedStrategy != newStrat) { - await Hive.box(HiveBoxNames.strategiesBox) - .put(migratedStrategy.id, migratedStrategy); - } - - ref.read(agentProvider.notifier).fromHive(page.agentData); - ref.read(abilityProvider.notifier).fromHive(page.abilityData); - ref.read(drawingProvider.notifier).fromHive(page.drawingData); - - ref - .read(mapProvider.notifier) - .fromHive(migratedStrategy.mapData, page.isAttack); - ref.read(textProvider.notifier).fromHive(page.textData); - ref.read(placedImageProvider.notifier).fromHive(page.imageData); - ref.read(lineUpProvider.notifier).fromHive(page.lineUpGraph); - ref.read(strategySettingsProvider.notifier).fromHive(page.settings); - ref.read(strategyThemeProvider.notifier).fromStrategy( - profileId: migratedStrategy.themeProfileId ?? - MapThemeProfilesProvider.immutableDefaultProfileId, - overridePalette: migratedStrategy.themeOverridePalette, - ); - ref.read(utilityProvider.notifier).fromHive(page.utilityData); - activePageID = page.id; - - if (kIsWeb) { - state = StrategyState( - isSaved: true, - stratName: migratedStrategy.name, - id: migratedStrategy.id, - storageDirectory: null, - activePageId: page.id, - ); return; } - final newDir = await setStorageDirectory(migratedStrategy.id); - - state = StrategyState( - isSaved: true, - stratName: migratedStrategy.name, - id: migratedStrategy.id, - storageDirectory: newDir.path, - activePageId: page.id, - ); - } - - Future loadFromFilePath(String filePath) async { - await _importStrategyFile( - file: XFile(filePath), - targetFolderId: null, - ); - } - - Future loadFromFilePicker() async { - FilePickerResult? result = await FilePicker.platform.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ["ica"], - ); - - if (result == null) return; - - for (PlatformFile file in result.files) { - await _importStrategyFile( - file: file.xFile, - targetFolderId: null, - ); - } - } - - Future importBackupFromFilePicker() async { - final result = await FilePicker.platform.pickFiles( - allowMultiple: false, - type: FileType.custom, - allowedExtensions: ['zip'], - ); - - if (result == null || result.files.isEmpty) { - return const ImportBatchResult.empty(); - } - - final PlatformFile pickedFile = result.files.single; - final String? filePath = pickedFile.path; - - if (filePath == null || filePath.isEmpty) { - // FilePicker can return files without a valid path (e.g. in-memory bytes). - // In that case, return an empty result instead of throwing. - return const ImportBatchResult.empty(); - } - - return _importZipArchive( - zipFile: File(filePath), - parentFolderId: null, - ); - } - - Future loadFromFileDrop(List files) async { - final targetFolderId = ref.read(folderProvider); - var result = const ImportBatchResult.empty(); - - for (XFile file in files) { - result = result.merge( - await _importDroppedItem( - file: file, - targetFolderId: targetFolderId, - ), - ); - } - - return result; - } - - Future getTempDirectory(String strategyID) async { - String tempDirectoryPath; - try { - tempDirectoryPath = (await getTemporaryDirectory()).path; - } on MissingPluginException { - tempDirectoryPath = Directory.systemTemp.path; - } on MissingPlatformDirectoryException { - tempDirectoryPath = Directory.systemTemp.path; - } - - Directory tempDir = await Directory( - path.join(tempDirectoryPath, "xyz_icarus_strats", strategyID)) - .create(recursive: true); - return tempDir; - } - - Future cleanUpTempDirectory(String strategyID) async { - final tempDirectory = await getTempDirectory(strategyID); - await tempDirectory.delete(recursive: true); - } - - Future _getApplicationSupportDirectoryOrSystemTemp() async { - try { - return await getApplicationSupportDirectory(); - } on MissingPluginException { - return Directory.systemTemp; - } on MissingPlatformDirectoryException { - return Directory.systemTemp; - } - } - Future _extractArchiveEntriesToDisk({ - required Archive archive, - required Directory destination, - }) async { - // Normalize the destination path once for comparisons. - final String destinationPath = path.normalize(destination.path); + final strategyId = state.strategyId; + if (strategyId == null) return; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null) return; - for (final entry in archive) { - final normalizedName = normalizeArchivePath(entry.name); - if (normalizedName.isEmpty) { - continue; - } - - // Reject absolute paths in the archive entry name. - if (path.isAbsolute(normalizedName)) { - continue; - } - - // Reject any entry that attempts directory traversal using "..". - final segments = path.posix.split(normalizedName); - if (segments.any((segment) => segment == '..')) { - continue; - } - - // Build the target path under the destination directory. - final targetPath = path.joinAll([ - destinationPath, - ...segments, - ]); - - // Normalize and verify that the target path stays within destination. - final normalizedTargetPath = path.normalize(targetPath); - final bool isWithinDestination = - path.isWithin(destinationPath, normalizedTargetPath) || - normalizedTargetPath == destinationPath; - if (!isWithinDestination) { - continue; - } - - if (entry.isFile) { - final targetFile = File(normalizedTargetPath); - await targetFile.parent.create(recursive: true); - await targetFile.writeAsBytes(entry.content as List); - } else { - await Directory(normalizedTargetPath).create(recursive: true); - } - } - } - - /// Returns true if the file is a ZIP (by checking the magic number) - Future isZipFile(File file) async { - // Read the first 4 bytes of the file - final raf = file.openSync(mode: FileMode.read); - final header = raf.readSync(4); - await raf.close(); - - // ZIP files start with 'PK\x03\x04' - return header.length == 4 && - header[0] == 0x50 && // 'P' - header[1] == 0x4B && // 'K' - header[2] == 0x03 && - header[3] == 0x04; - } - - Future _importDroppedItem({ - required XFile file, - required String? targetFolderId, - }) async { - if (file.path.isEmpty) { - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue(path: '', code: ImportIssueCode.ioError), - ], - ); - } - - try { - final entityType = - await FileSystemEntity.type(file.path, followLinks: false); - switch (entityType) { - case FileSystemEntityType.directory: - return await _importDirectoryTree( - sourceDir: Directory(file.path), - parentFolderId: targetFolderId, - ); - case FileSystemEntityType.file: - final extension = path.extension(file.path).toLowerCase(); - if (extension == '.ica') { - await _importStrategyFile( - file: file, - targetFolderId: targetFolderId, - ); - return const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ); - } - - if (await isZipFile(File(file.path))) { - return await _importZipArchive( - zipFile: File(file.path), - parentFolderId: targetFolderId, - ); - } - - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.unsupportedFile, - ), - ], - ); - case FileSystemEntityType.notFound: - case FileSystemEntityType.link: - case FileSystemEntityType.unixDomainSock: - case FileSystemEntityType.pipe: - default: - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.ioError, - ), - ], - ); - } - } on NewerVersionImportException { - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.newerVersion, - ), - ], - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import dropped item ${file.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importDroppedItem', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: file.path, - code: ImportIssueCode.ioError, - ), - ], - ); - } - } - - Future _createImportedFolder({ - required String name, - required String? parentFolderId, - }) { - return ref.read(folderProvider.notifier).createFolder( - name: name, - iconId: FolderIconRegistry.idForLegacyIconData( - Icons.drive_folder_upload, - ), - color: FolderColor.generic, - parentID: parentFolderId, - ); - } - - List _sortedImportEntities( - Iterable entities, - ) { - final filtered = entities.where((entity) { - final basename = path.basename(entity.path); - return !_shouldIgnoreImportedEntityName(basename); - }).toList(); - filtered.sort((a, b) => a.path.compareTo(b.path)); - return filtered; - } - - bool _shouldIgnoreImportedEntityName(String name) { - return name.isEmpty || - name == '__MACOSX' || - name == '.DS_Store' || - name == archiveMetadataFileName || - name.startsWith('._'); - } - - bool _isIcaFileEntity(FileSystemEntity entity) { - return entity is File && - path.extension(entity.path).toLowerCase() == '.ica'; - } - - Future<_ImportEntityListing> _listImportEntities(Directory directory) async { - final issues = []; - try { - final entities = directory.listSync(followLinks: false); - return _ImportEntityListing( - entities: _sortedImportEntities(entities), - issues: issues, - ); - } on FileSystemException catch (error, stackTrace) { - final errorPath = _resolveImportErrorPath(error, directory.path); - _reportImportFailure( - 'Failed to list import directory $errorPath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._listImportEntities', - ); - issues.add( - ImportIssue( - path: errorPath, - code: ImportIssueCode.ioError, - ), - ); - } - - return _ImportEntityListing( - entities: const [], - issues: issues, - ); - } - - String _resolveImportErrorPath(Object error, String fallbackPath) { - if (error is FileSystemException) { - return error.path ?? fallbackPath; - } - return fallbackPath; - } - - Future _importEntitiesIntoFolder({ - required Iterable entities, - required String parentFolderId, - }) async { - var result = const ImportBatchResult.empty(); - final sortedEntities = _sortedImportEntities(entities); - - for (final entity in sortedEntities) { - if (entity is Directory) { - result = result.merge( - await _importDirectoryTree( - sourceDir: entity, - parentFolderId: parentFolderId, - ), - ); - continue; - } - - if (_isIcaFileEntity(entity)) { - try { - await _importStrategyFile( - file: XFile(entity.path), - targetFolderId: parentFolderId, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import strategy file ${entity.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importEntitiesIntoFolder', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - continue; - } - - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ], - ), - ); - } - - return result; - } - - Future _importDirectoryTree({ - required Directory sourceDir, - required String? parentFolderId, - }) async { - final manifestFile = - File(path.join(sourceDir.path, archiveMetadataFileName)); - _ManifestImportData? manifestData; - if (await manifestFile.exists()) { - try { - manifestData = await _loadManifestIfPresent(sourceDir); - if (manifestData != null) { - _validateArchiveManifest(manifestData); - } - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest archive from ${sourceDir.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importDirectoryTree', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: manifestFile.path, - code: ImportIssueCode.invalidArchiveMetadata, - ), - ], - ).merge( - await _importDirectoryTreeLegacy( - sourceDir: sourceDir, - parentFolderId: parentFolderId, - ), - ); - } - } - - if (manifestData != null) { - return _importManifestArchive( - manifestData: manifestData, - parentFolderId: parentFolderId, - ); - } - - return _importDirectoryTreeLegacy( - sourceDir: sourceDir, - parentFolderId: parentFolderId, - ); - } - - Future _importDirectoryTreeLegacy({ - required Directory sourceDir, - required String? parentFolderId, - }) async { - final importedFolder = await _createImportedFolder( - name: path.basename(sourceDir.path), - parentFolderId: parentFolderId, - ); - - var result = const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ); - - final listing = await _listImportEntities(sourceDir); - if (listing.issues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: listing.issues, - ), - ); - } - - result = result.merge( - await _importEntitiesIntoFolder( - entities: listing.entities, - parentFolderId: importedFolder.id, - ), - ); - - return result; - } - - Future _importZipArchive({ - required File zipFile, - required String? parentFolderId, - }) async { - final archive = ZipDecoder().decodeBytes(await zipFile.readAsBytes()); - _ZipManifestData? manifestData; - try { - manifestData = _loadManifestFromArchive(archive); - if (manifestData != null) { - _validateArchiveManifestFromZip(manifestData); - } - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest zip ${zipFile.path}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importZipArchive', - ); - return ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: zipFile.path, - code: ImportIssueCode.invalidArchiveMetadata, - ), - ], - ).merge( - await _importLegacyZipArchiveFromEntries( - archive: archive, - parentFolderId: parentFolderId, - zipFileName: path.basenameWithoutExtension(zipFile.path), - ), - ); - } - - if (manifestData != null) { - return _importManifestArchiveFromZip( - manifestData: manifestData, - parentFolderId: parentFolderId, - ); - } - - return _importLegacyZipArchiveFromEntries( - archive: archive, - parentFolderId: parentFolderId, - zipFileName: path.basenameWithoutExtension(zipFile.path), - ); - } - - _ZipManifestData? _loadManifestFromArchive(Archive archive) { - final filesByPath = {}; - for (final entry in archive) { - if (!entry.isFile) { - continue; - } - filesByPath[normalizeArchivePath(entry.name)] = entry; - } - - final manifestPaths = filesByPath.keys - .where((pathValue) => - path.posix.basename(pathValue) == archiveMetadataFileName) - .toList(growable: false); - if (manifestPaths.isEmpty) { - return null; - } - if (manifestPaths.length > 1) { - throw const FormatException('Archive contains multiple manifest files'); - } - - final manifestArchivePath = manifestPaths.single; - final manifestEntry = filesByPath[manifestArchivePath]!; - final decoded = jsonDecode(utf8.decode(_archiveFileBytes(manifestEntry))); - if (decoded is! Map) { - throw const FormatException('Archive manifest must decode to an object'); - } - - final rootPrefix = path.posix.dirname(manifestArchivePath); - return _ZipManifestData( - manifest: ArchiveManifest.fromJson(decoded), - rootPrefix: rootPrefix == '.' ? '' : rootPrefix, - filesByPath: filesByPath, - manifestArchivePath: manifestArchivePath, - ); - } - - List _archiveFileBytes(ArchiveFile entry) { - return entry.content as List; - } - - Future _writeArchiveEntryToTempFile({ - required ArchiveFile archiveFile, - required Directory tempDirectory, - }) async { - final baseName = path.basename(normalizeArchivePath(archiveFile.name)); - final file = File(path.join(tempDirectory.path, baseName)); - await file.parent.create(recursive: true); - await file.writeAsBytes(_archiveFileBytes(archiveFile)); - return file; - } - - Future _importManifestArchiveFromZip({ - required _ZipManifestData manifestData, - required String? parentFolderId, - }) async { - var result = const ImportBatchResult.empty(); - var profileIdRemap = const {}; - - if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { - final globals = manifestData.manifest.globals; - if (globals == null) { - throw const FormatException( - 'Library backup archive is missing globals'); - } - final globalImportResult = await _importArchiveGlobals(globals); - profileIdRemap = globalImportResult.profileIdRemap; - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: globalImportResult.themeProfilesImported, - globalStateRestored: globalImportResult.globalStateRestored, - issues: const [], - ), - ); - } - - final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { - final depthCompare = _archivePathDepth(a.archivePath) - .compareTo(_archivePathDepth(b.archivePath)); - if (depthCompare != 0) { - return depthCompare; - } - return a.archivePath.compareTo(b.archivePath); - }); - - final localFolderIdsByManifestId = {}; - for (final folderEntry in folderEntries) { - final resolvedParentFolderId = folderEntry.parentManifestId == null - ? (manifestData.manifest.archiveType == ArchiveType.folderTree - ? parentFolderId - : null) - : localFolderIdsByManifestId[folderEntry.parentManifestId!]; - if (folderEntry.parentManifestId != null && - resolvedParentFolderId == null) { - throw FormatException( - 'Missing parent folder mapping for ${folderEntry.manifestId}', - ); - } - - final createdFolder = - await ref.read(folderProvider.notifier).createFolder( - name: folderEntry.name, - iconId: folderEntry.iconId, - color: folderEntry.color, - customColor: folderEntry.customColorValue == null - ? null - : Color(folderEntry.customColorValue!), - parentID: resolvedParentFolderId, - ); - localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; - result = result.merge( - const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } - - final materializedDirectory = - await Directory.systemTemp.createTemp('icarus-zip-manifest-import'); - try { - for (final strategyEntry in [...manifestData.manifest.strategies] - ..sort((a, b) => a.archivePath.compareTo(b.archivePath))) { - final targetFolderId = strategyEntry.folderManifestId == null - ? null - : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; - final archivePath = _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategyEntry.archivePath, - ); - final archiveFile = manifestData.filesByPath[archivePath]; - if (archiveFile == null) { - throw FormatException('Missing strategy file: $archivePath'); - } - - try { - final tempFile = await _writeArchiveEntryToTempFile( - archiveFile: archiveFile, - tempDirectory: materializedDirectory, - ); - await _importStrategyFile( - file: XFile(tempFile.path), - targetFolderId: targetFolderId, - displayNameOverride: strategyEntry.name, - themeProfileIdRemap: profileIdRemap, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest strategy $archivePath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importManifestArchiveFromZip', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - } finally { - try { - await materializedDirectory.delete(recursive: true); - } catch (_) {} - } - - final undeclaredIssues = _collectUndeclaredZipArchiveIssues(manifestData); - if (undeclaredIssues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: undeclaredIssues, - ), - ); - } - - return result; - } - - void _validateArchiveManifestFromZip(_ZipManifestData manifestData) { - final manifest = manifestData.manifest; - final folderIds = {}; - final folderPaths = {}; - final rootFolders = []; - - for (final folder in manifest.folders) { - if (!folderIds.add(folder.manifestId)) { - throw FormatException( - 'Duplicate folder manifest ID: ${folder.manifestId}'); - } - if (!folderPaths.add(folder.archivePath)) { - throw FormatException( - 'Duplicate folder archive path: ${folder.archivePath}'); - } - if (folder.parentManifestId == null) { - rootFolders.add(folder); - } else if (!manifest.folders.any( - (candidate) => candidate.manifestId == folder.parentManifestId)) { - throw FormatException('Missing parent folder for ${folder.manifestId}'); - } - } - - if (manifest.archiveType == ArchiveType.folderTree) { - if (rootFolders.length != 1) { - throw const FormatException( - 'Folder tree archives must contain one root'); - } - if (rootFolders.single.archivePath.isNotEmpty) { - throw const FormatException( - 'Folder tree root folder must use the manifest root path', - ); - } - } - - final knownFolderIds = - manifest.folders.map((folder) => folder.manifestId).toSet(); - final strategyPaths = {}; - for (final strategy in manifest.strategies) { - if (!strategyPaths.add(strategy.archivePath)) { - throw FormatException( - 'Duplicate strategy archive path: ${strategy.archivePath}', - ); - } - if (strategy.folderManifestId != null && - !knownFolderIds.contains(strategy.folderManifestId)) { - throw FormatException( - 'Unknown strategy folder reference: ${strategy.folderManifestId}', - ); - } - if (manifest.archiveType == ArchiveType.folderTree && - strategy.folderManifestId == null) { - throw const FormatException( - 'Folder tree strategies must reference the exported root folder', - ); - } - final archivePath = _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategy.archivePath, - ); - if (!manifestData.filesByPath.containsKey(archivePath)) { - throw FormatException('Missing strategy file: $archivePath'); - } - } - } - - String _zipArchiveAbsolutePath({ - required String rootPrefix, - required String relativePath, - }) { - return normalizeArchivePath( - rootPrefix.isEmpty - ? relativePath - : path.posix.join(rootPrefix, relativePath), - ); - } - - List _collectUndeclaredZipArchiveIssues( - _ZipManifestData manifestData, - ) { - final allowedFiles = {manifestData.manifestArchivePath}; - for (final strategy in manifestData.manifest.strategies) { - allowedFiles.add( - _zipArchiveAbsolutePath( - rootPrefix: manifestData.rootPrefix, - relativePath: strategy.archivePath, - ), - ); - } - - final issues = []; - for (final archivePath in manifestData.filesByPath.keys) { - if (!allowedFiles.contains(archivePath) && - !_shouldIgnoreImportedEntityName(path.posix.basename(archivePath))) { - issues.add( - ImportIssue( - path: archivePath, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - } - return issues; - } - - Future _importLegacyZipArchiveFromEntries({ - required Archive archive, - required String? parentFolderId, - required String zipFileName, - }) async { - final filesByPath = {}; - for (final entry in archive) { - if (!entry.isFile) { - continue; - } - final normalizedPath = normalizeArchivePath(entry.name); - if (_shouldIgnoreImportedEntityName( - path.posix.basename(normalizedPath))) { - continue; - } - filesByPath[normalizedPath] = entry; - } - - final topLevelSegments = {}; - final looseTopLevelIca = []; - for (final archivePath in filesByPath.keys) { - final segments = archivePath.split('/'); - if (segments.isEmpty) { - continue; - } - topLevelSegments.add(segments.first); - if (segments.length == 1 && - path.extension(archivePath).toLowerCase() == '.ica') { - looseTopLevelIca.add(archivePath); - } - } - - if (topLevelSegments.length == 1 && looseTopLevelIca.isEmpty) { - return _importLegacyZipDirectory( - directoryPrefix: topLevelSegments.single, - filesByPath: filesByPath, - parentFolderId: parentFolderId, - ); - } - - final wrapperFolder = await _createImportedFolder( - name: zipFileName, - parentFolderId: parentFolderId, - ); - - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ).merge( - await _importLegacyZipEntitiesIntoFolder( - parentPrefix: '', - filesByPath: filesByPath, - parentFolderId: wrapperFolder.id, - ), - ); - } - - Future _importLegacyZipDirectory({ - required String directoryPrefix, - required Map filesByPath, - required String? parentFolderId, - }) async { - final importedFolder = await _createImportedFolder( - name: path.posix.basename(directoryPrefix), - parentFolderId: parentFolderId, - ); - - return const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ).merge( - await _importLegacyZipEntitiesIntoFolder( - parentPrefix: directoryPrefix, - filesByPath: filesByPath, - parentFolderId: importedFolder.id, - ), - ); - } - - Future _importLegacyZipEntitiesIntoFolder({ - required String parentPrefix, - required Map filesByPath, - required String parentFolderId, - }) async { - final directDirectories = {}; - final directFiles = []; - final normalizedParentPrefix = normalizeArchivePath(parentPrefix); - - for (final archivePath in filesByPath.keys) { - final parentPath = path.posix.dirname(archivePath); - if (normalizedParentPrefix.isEmpty) { - if (parentPath == '.') { - directFiles.add(archivePath); - } else if (!parentPath.contains('/')) { - directDirectories.add(parentPath); - } - continue; - } - - if (parentPath == normalizedParentPrefix) { - directFiles.add(archivePath); - continue; - } - - if (archivePath.startsWith('$normalizedParentPrefix/')) { - final remainder = - archivePath.substring(normalizedParentPrefix.length + 1); - if (remainder.isEmpty || !remainder.contains('/')) { - continue; - } - final childDirectory = remainder.substring(0, remainder.indexOf('/')); - directDirectories.add( - normalizeArchivePath( - path.posix.join(normalizedParentPrefix, childDirectory), - ), - ); - } - } - - var result = const ImportBatchResult.empty(); - - final tempDirectory = - await Directory.systemTemp.createTemp('icarus-zip-legacy-import'); - try { - final sortedDirectories = directDirectories.toList()..sort(); - for (final directoryPrefix in sortedDirectories) { - result = result.merge( - await _importLegacyZipDirectory( - directoryPrefix: directoryPrefix, - filesByPath: filesByPath, - parentFolderId: parentFolderId, - ), - ); - } - - directFiles.sort(); - for (final archivePath in directFiles) { - if (path.extension(archivePath).toLowerCase() != '.ica') { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.unsupportedFile, - ), - ], - ), - ); - continue; - } - - try { - final tempFile = await _writeArchiveEntryToTempFile( - archiveFile: filesByPath[archivePath]!, - tempDirectory: tempDirectory, - ); - await _importStrategyFile( - file: XFile(tempFile.path), - targetFolderId: parentFolderId, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import zip strategy $archivePath.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importLegacyZipEntitiesIntoFolder', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - } finally { - try { - await tempDirectory.delete(recursive: true); - } catch (_) {} - } - - return result; - } - - Future<_ManifestImportData?> _loadManifestIfPresent( - Directory directory) async { - final manifestFile = - File(path.join(directory.path, archiveMetadataFileName)); - if (!await manifestFile.exists()) { - return null; - } - - final raw = await manifestFile.readAsString(); - final decoded = jsonDecode(raw); - if (decoded is! Map) { - throw const FormatException('Archive metadata must decode to an object'); - } - - return _ManifestImportData( - rootDirectory: directory, - manifestFile: manifestFile, - manifest: ArchiveManifest.fromJson(decoded), - ); - } - - Future _importManifestArchive({ - required _ManifestImportData manifestData, - required String? parentFolderId, - }) async { - _validateArchiveManifest(manifestData); - - var result = const ImportBatchResult.empty(); - var profileIdRemap = const {}; - - if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { - final globals = manifestData.manifest.globals; - if (globals == null) { - throw const FormatException( - 'Library backup archive is missing globals'); - } - final globalImportResult = await _importArchiveGlobals(globals); - profileIdRemap = globalImportResult.profileIdRemap; - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: globalImportResult.themeProfilesImported, - globalStateRestored: globalImportResult.globalStateRestored, - issues: const [], - ), - ); - } - - final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { - final depthCompare = _archivePathDepth(a.archivePath) - .compareTo(_archivePathDepth(b.archivePath)); - if (depthCompare != 0) { - return depthCompare; - } - return a.archivePath.compareTo(b.archivePath); - }); - - final localFolderIdsByManifestId = {}; - for (final folderEntry in folderEntries) { - final resolvedParentFolderId = folderEntry.parentManifestId == null - ? (manifestData.manifest.archiveType == ArchiveType.folderTree - ? parentFolderId - : null) - : localFolderIdsByManifestId[folderEntry.parentManifestId!]; - if (folderEntry.parentManifestId != null && - resolvedParentFolderId == null) { - throw FormatException( - 'Missing parent folder mapping for ${folderEntry.manifestId}', - ); - } - - final createdFolder = - await ref.read(folderProvider.notifier).createFolder( - name: folderEntry.name, - iconId: folderEntry.iconId, - color: folderEntry.color, - customColor: folderEntry.customColorValue == null - ? null - : Color(folderEntry.customColorValue!), - parentID: resolvedParentFolderId, - ); - localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; - result = result.merge( - const ImportBatchResult( - strategiesImported: 0, - foldersCreated: 1, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } - - final strategyEntries = [...manifestData.manifest.strategies] - ..sort((a, b) => a.archivePath.compareTo(b.archivePath)); - for (final strategyEntry in strategyEntries) { - final targetFolderId = strategyEntry.folderManifestId == null - ? null - : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; - if (strategyEntry.folderManifestId != null && targetFolderId == null) { - throw FormatException( - 'Missing folder mapping for strategy ${strategyEntry.archivePath}', - ); - } - - try { - await _importStrategyFile( - file: XFile( - _archivePathToFile( - manifestData.rootDirectory, strategyEntry.archivePath) - .path, - ), - targetFolderId: targetFolderId, - displayNameOverride: strategyEntry.name, - themeProfileIdRemap: profileIdRemap, - ); - result = result.merge( - const ImportBatchResult( - strategiesImported: 1, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [], - ), - ); - } on NewerVersionImportException { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: strategyEntry.archivePath, - code: ImportIssueCode.newerVersion, - ), - ], - ), - ); - } catch (error, stackTrace) { - _reportImportFailure( - 'Failed to import manifest strategy ${strategyEntry.archivePath}.', - error: error, - stackTrace: stackTrace, - source: 'StrategyProvider._importManifestArchive', - ); - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: [ - ImportIssue( - path: strategyEntry.archivePath, - code: ImportIssueCode.invalidStrategy, - ), - ], - ), - ); - } - } - - final undeclaredIssues = await _collectUndeclaredArchiveIssues( - manifestData: manifestData, - ); - if (undeclaredIssues.isNotEmpty) { - result = result.merge( - ImportBatchResult( - strategiesImported: 0, - foldersCreated: 0, - themeProfilesImported: 0, - globalStateRestored: false, - issues: undeclaredIssues, - ), - ); - } - - return result; - } - - void _validateArchiveManifest(_ManifestImportData manifestData) { - final manifest = manifestData.manifest; - final folderIds = {}; - final folderPaths = {}; - final rootFolders = []; - - for (final folder in manifest.folders) { - if (!folderIds.add(folder.manifestId)) { - throw FormatException( - 'Duplicate folder manifest ID: ${folder.manifestId}'); - } - if (!folderPaths.add(folder.archivePath)) { - throw FormatException( - 'Duplicate folder archive path: ${folder.archivePath}'); - } - if (folder.parentManifestId == null) { - rootFolders.add(folder); - } else if (!manifest.folders.any( - (candidate) => candidate.manifestId == folder.parentManifestId)) { - throw FormatException('Missing parent folder for ${folder.manifestId}'); - } - } - - if (manifest.archiveType == ArchiveType.folderTree) { - if (rootFolders.length != 1) { - throw const FormatException( - 'Folder tree archives must contain one root'); - } - if (rootFolders.single.archivePath.isNotEmpty) { - throw const FormatException( - 'Folder tree root folder must use the manifest root path', - ); - } - } - - final knownFolderIds = - manifest.folders.map((folder) => folder.manifestId).toSet(); - final strategyPaths = {}; - for (final strategy in manifest.strategies) { - if (!strategyPaths.add(strategy.archivePath)) { - throw FormatException( - 'Duplicate strategy archive path: ${strategy.archivePath}', - ); - } - if (strategy.folderManifestId != null && - !knownFolderIds.contains(strategy.folderManifestId)) { - throw FormatException( - 'Unknown strategy folder reference: ${strategy.folderManifestId}', - ); - } - if (manifest.archiveType == ArchiveType.folderTree && - strategy.folderManifestId == null) { - throw const FormatException( - 'Folder tree strategies must reference the exported root folder', - ); - } - if (!(_archivePathToFile(manifestData.rootDirectory, strategy.archivePath) - .existsSync())) { - throw FormatException('Missing strategy file: ${strategy.archivePath}'); - } - } - } - - int _archivePathDepth(String archivePath) { - if (archivePath.isEmpty) { - return 0; - } - return archivePath.split('/').length; - } - - File _archivePathToFile(Directory rootDirectory, String archivePath) { - final normalized = normalizeArchivePath(archivePath); - final segments = - normalized.isEmpty ? const [] : normalized.split('/'); - return File(path.joinAll([rootDirectory.path, ...segments])); - } - - Future> _collectUndeclaredArchiveIssues({ - required _ManifestImportData manifestData, - }) async { - final allowedFiles = {archiveMetadataFileName}; - final allowedDirectories = {}; - - void addAllowedDirectoryAncestors(String archivePath) { - var current = normalizeArchivePath(archivePath); - if (current.isEmpty) { - return; - } - while (current.isNotEmpty && current != '.') { - allowedDirectories.add(current); - final parent = path.posix.dirname(current); - if (parent == '.' || parent == current) { - break; - } - current = parent; - } - } - - for (final folder in manifestData.manifest.folders) { - addAllowedDirectoryAncestors(folder.archivePath); - } - for (final strategy in manifestData.manifest.strategies) { - final normalizedPath = normalizeArchivePath(strategy.archivePath); - allowedFiles.add(normalizedPath); - final parentDirectory = path.posix.dirname(normalizedPath); - if (parentDirectory != '.') { - addAllowedDirectoryAncestors(parentDirectory); - } - } - - final issues = []; - await for (final entity in manifestData.rootDirectory - .list(recursive: true, followLinks: false)) { - final relativePath = normalizeArchivePath( - path.relative(entity.path, from: manifestData.rootDirectory.path), - ); - if (relativePath.isEmpty) { - continue; - } - - if (entity is File) { - if (!allowedFiles.contains(relativePath)) { - issues.add( - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - continue; - } - - final directoryAllowed = allowedDirectories.contains(relativePath) || - allowedFiles.any((allowed) => allowed.startsWith('$relativePath/')); - if (!directoryAllowed) { - issues.add( - ImportIssue( - path: entity.path, - code: ImportIssueCode.unsupportedFile, - ), - ); - } - } - - return issues; - } - - Future<_GlobalImportResult> _importArchiveGlobals( - ArchiveGlobals globals) async { - await MapThemeProfilesProvider.bootstrap(); - - final profileBox = - Hive.box(HiveBoxNames.mapThemeProfilesBox); - final appPreferencesBox = - Hive.box(HiveBoxNames.appPreferencesBox); - final favoriteAgentsBox = Hive.box(HiveBoxNames.favoriteAgentsBox); - - final profileIdRemap = {}; - var themeProfilesImported = 0; - - final existingProfiles = profileBox.values.toList(); - for (final importedProfile in globals.themeProfiles) { - if (importedProfile.isBuiltIn) { - if (profileBox.get(importedProfile.id) != null) { - profileIdRemap[importedProfile.id] = importedProfile.id; - } - continue; - } - - final matchingExisting = existingProfiles.firstWhere( - (existing) => - !existing.isBuiltIn && - existing.name == importedProfile.name && - existing.palette == importedProfile.palette, - orElse: () => MapThemeProfile( - id: '', - name: '', - palette: MapThemeProfilesProvider.immutableDefaultPalette, - isBuiltIn: false, - ), - ); - - if (matchingExisting.id.isNotEmpty) { - profileIdRemap[importedProfile.id] = matchingExisting.id; - continue; - } - - var localProfileId = importedProfile.id; - if (profileBox.get(localProfileId) != null || - MapThemeProfilesProvider.immutableBuiltInProfiles - .any((profile) => profile.id == localProfileId)) { - localProfileId = const Uuid().v4(); - } - - final createdProfile = MapThemeProfile( - id: localProfileId, - name: importedProfile.name, - palette: importedProfile.palette, - isBuiltIn: false, - ); - await profileBox.put(createdProfile.id, createdProfile); - existingProfiles.add(createdProfile); - profileIdRemap[importedProfile.id] = createdProfile.id; - themeProfilesImported++; - } - - final resolvedDefaultProfileId = globals - .defaultThemeProfileIdForNewStrategies == - null - ? MapThemeProfilesProvider.immutableDefaultProfileId - : profileIdRemap[globals.defaultThemeProfileIdForNewStrategies!] ?? - (profileBox.get(globals.defaultThemeProfileIdForNewStrategies!) != - null - ? globals.defaultThemeProfileIdForNewStrategies! - : MapThemeProfilesProvider.immutableDefaultProfileId); - - await appPreferencesBox.put( - MapThemeProfilesProvider.appPreferencesSingletonKey, - (appPreferencesBox - .get(MapThemeProfilesProvider.appPreferencesSingletonKey) ?? - AppPreferences( - defaultThemeProfileIdForNewStrategies: - MapThemeProfilesProvider.immutableDefaultProfileId, - )) - .copyWith( - defaultThemeProfileIdForNewStrategies: resolvedDefaultProfileId, - showSpawnBarrier: globals.showSpawnBarrier, - showUltOrbs: globals.showUltOrbs, - showRegionNames: globals.showRegionNames, - customColorValues: globals.customColorValues, - ), - ); - - await favoriteAgentsBox.clear(); - for (final favorite in globals.favoriteAgentTypes()) { - await favoriteAgentsBox.put(favorite.name, true); - } - - await ref.read(mapThemeProfilesProvider.notifier).refreshFromHive(); - await ref.read(appPreferencesProvider.notifier).refreshFromHive(); - ref - .read(mapProvider.notifier) - .syncVisibilityFromPreferences(ref.read(appPreferencesProvider)); - ref.invalidate(favoriteAgentsProvider); - - return _GlobalImportResult( - themeProfilesImported: themeProfilesImported, - globalStateRestored: true, - profileIdRemap: profileIdRemap, - ); - } - - Future _importStrategyFile({ - required XFile file, - required String? targetFolderId, - String? displayNameOverride, - Map themeProfileIdRemap = const {}, - }) async { - final newID = const Uuid().v4(); - final bool isZip = await isZipFile(File(file.path)); - - log("Is ZIP file: $isZip"); - final bytes = await file.readAsBytes(); - String jsonData = ""; - - try { - if (isZip) { - // Decode the Zip file - final archive = ZipDecoder().decodeBytes(bytes); - - final imageFolder = await PlacedImageProvider.getImageFolder(newID); - final tempDirectory = await getTempDirectory(newID); - - await _extractArchiveEntriesToDisk( - archive: archive, - destination: tempDirectory, - ); - - final tempDirectoryList = tempDirectory.listSync(); - log("Temp directory list: ${tempDirectoryList.length}."); - - for (final fileEntity in tempDirectoryList) { - if (fileEntity is File) { - log(fileEntity.path); - if (path.extension(fileEntity.path) == ".json") { - log("Found JSON file"); - jsonData = await fileEntity.readAsString(); - } else if (path.extension(fileEntity.path) != ".ica") { - final fileName = path.basename(fileEntity.path); - await fileEntity.copy(path.join(imageFolder.path, fileName)); - } - } - } - if (jsonData.isEmpty) { - throw Exception("No .ica file found"); - } - } else { - jsonData = await file.readAsString(); - } - - Map json = jsonDecode(jsonData); - final versionNumber = int.tryParse(json["versionNumber"].toString()) ?? - Settings.versionNumber; - _throwIfImportedVersionIsTooNew(versionNumber); - - //Backwards compatibility for pre-pages exported strategies - final List drawingData = - DrawingProvider.fromJson(jsonEncode(json["drawingData"] ?? [])); - - final List agentData = - AgentProvider.fromJson(jsonEncode(json["agentData"] ?? [])) - .whereType() - .toList(growable: false); - - final List abilityData = - AbilityProvider.fromJson(jsonEncode(json["abilityData"] ?? [])); - - final mapData = MapProvider.fromJson(jsonEncode(json["mapData"])); - final textData = - TextProvider.fromJson(jsonEncode(json["textData"] ?? [])); - - List imageData = []; - if (!kIsWeb) { - if (isZip) { - imageData = await PlacedImageProvider.fromJson( - jsonString: jsonEncode(json["imageData"] ?? []), - strategyID: newID); - } else { - log('Legacy image data loading'); - imageData = await PlacedImageProvider.legacyFromJson( - jsonString: jsonEncode(json["imageData"] ?? []), - strategyID: newID); - } - } - - final StrategySettings settingsData; - final bool isAttack; - final List utilityData; - - if (json["settingsData"] != null) { - settingsData = ref - .read(strategySettingsProvider.notifier) - .fromJson(jsonEncode(json["settingsData"])); - } else { - settingsData = StrategySettings(); - } - - if (json["isAttack"] != null) { - isAttack = json["isAttack"] == "true" ? true : false; - } else { - isAttack = true; - } - - if (json["utilityData"] != null) { - utilityData = UtilityProvider.fromJson(jsonEncode(json["utilityData"])); - } else { - utilityData = []; - } - final MapThemePalette? importedThemeOverridePalette = - json["themePalette"] is Map - ? MapThemePalette.fromJson(json["themePalette"]) - : (json["themePalette"] is Map - ? MapThemePalette.fromJson( - Map.from(json["themePalette"])) - : null); - final rawImportedThemeProfileId = json['themeProfileId']; - final importedThemeProfileId = rawImportedThemeProfileId is String && - rawImportedThemeProfileId.isNotEmpty - ? rawImportedThemeProfileId - : null; - final String? resolvedThemeProfileId = importedThemeProfileId == null - ? null - : (themeProfileIdRemap[importedThemeProfileId] ?? - importedThemeProfileId); - - // bool needsMigration = (versionNumber < 15); - final List pages = json["pages"] != null - ? await StrategyPage.listFromJson( - json: jsonEncode(json["pages"]), - strategyID: newID, - isZip: isZip, - ) - : []; - - StrategyData newStrategy = StrategyData( - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - drawingData: drawingData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - agentData: agentData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - abilityData: abilityData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - textData: textData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - imageData: imageData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - utilityData: utilityData, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - isAttack: isAttack, - // ignore: deprecated_member_use_from_same_package, deprecated_member_use - strategySettings: settingsData, - - pages: pages, - id: newID, - name: displayNameOverride ?? path.basenameWithoutExtension(file.name), - mapData: mapData, - versionNumber: versionNumber, - lastEdited: DateTime.now(), - - folderID: targetFolderId, - themeProfileId: resolvedThemeProfileId, - themeOverridePalette: resolvedThemeProfileId == null - ? importedThemeOverridePalette - : null, - ); - - newStrategy = await migrateLegacyData(newStrategy); - - await Hive.box(HiveBoxNames.strategiesBox) - .put(newStrategy.id, newStrategy); - unawaited(AnalyticsService.instance.capture('strategy_imported')); - } finally { - if (isZip) { - try { - await cleanUpTempDirectory(newID); - } catch (_) {} - } - } - } - - static bool isNewerVersionImportError(Object error) { - return error is NewerVersionImportException; - } - - @visibleForTesting - static void throwIfImportedVersionIsTooNewForTest(int importedVersion) { - _throwIfImportedVersionIsTooNew(importedVersion); - } - - static void _throwIfImportedVersionIsTooNew(int importedVersion) { - if (importedVersion <= Settings.versionNumber) { - return; - } - - throw NewerVersionImportException( - importedVersion: importedVersion, - currentVersion: Settings.versionNumber, - ); - } - - /// Creates an empty strategy on [map] and returns it. Without [name] it - /// is auto-named after the map ("Haven", then "Haven 2", ...). - Future createNewStrategy({ - required MapValue map, - String? name, - }) async { - final box = Hive.box(HiveBoxNames.strategiesBox); - final strategyName = name ?? - autoStrategyName(map, box.values.map((strategy) => strategy.name)); - final newID = const Uuid().v4(); - final pageID = const Uuid().v4(); - final defaultThemeProfileId = - ref.read(mapThemeProfilesProvider).defaultProfileIdForNewStrategies; - final appPreferences = ref.read(appPreferencesProvider); - final defaultSettings = StrategySettings( - agentSize: appPreferences.defaultAgentSizeForNewStrategies, - abilitySize: appPreferences.defaultAbilitySizeForNewStrategies, - useNeutralTeamColors: - appPreferences.defaultNeutralTeamColorsForNewStrategies, - ); - final newStrategy = StrategyData( - mapData: map, - versionNumber: Settings.versionNumber, - id: newID, - name: strategyName, - pages: [ - StrategyPage( - id: pageID, - name: "Page 1", - isAutoNamed: true, - drawingData: [], - agentData: [], - abilityData: [], - textData: [], - imageData: [], - utilityData: [], - sortIndex: 0, - isAttack: true, - settings: defaultSettings, - ) - ], - lastEdited: DateTime.now(), - - // ignore: deprecated_member_use_from_same_package - strategySettings: defaultSettings, - folderID: ref.read(folderProvider), - themeProfileId: defaultThemeProfileId, - ); - - await box.put(newStrategy.id, newStrategy); - - unawaited(AnalyticsService.instance.capture('strategy_created')); - - return newStrategy; - } - - void setThemeProfileForCurrentStrategy(String profileId) { - ref.read(strategyThemeProvider.notifier).setProfile(profileId); - setUnsaved(); - } - - void setThemeOverrideForCurrentStrategy(MapThemePalette palette) { - ref.read(strategyThemeProvider.notifier).setOverride(palette); - setUnsaved(); - } - - void clearThemeOverrideForCurrentStrategy() { - ref.read(strategyThemeProvider.notifier).clearOverride(); - setUnsaved(); - } - - Future _flushCurrentStrategyIfNeeded() async { - if (!_hasLoadedStrategy) { - return; - } - await forceSaveNow(state.id); - } - - Future exportFolder(String folderID) async { - final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); - if (folder == null) { - log("Couldn't find folder to export"); - return; - } - - await _flushCurrentStrategyIfNeeded(); - final stagingDirectory = await buildFolderExportDirectoryForTest(folderID); - - try { - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: "${sanitizeFileName(folder.name)}.zip", - allowedExtensions: ['zip'], - ); - - if (outputFile == null) return; - - final encoder = ZipFileEncoder(); - encoder.create(outputFile); - await encoder.addDirectory(stagingDirectory, includeDirName: false); - await encoder.close(); - unawaited( - AnalyticsService.instance.capture( - 'content_exported', - properties: const {'content_type': 'folder'}, - ), - ); - } finally { - try { - await stagingDirectory.delete(recursive: true); - } catch (_) {} - } - } - - Future exportLibrary() async { - await _flushCurrentStrategyIfNeeded(); - final stagingDirectory = await buildLibraryExportDirectoryForTest(); - - try { - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: _buildLibraryBackupFileName(DateTime.now()), - allowedExtensions: ['zip'], - ); - - if (outputFile == null) return; - - final encoder = ZipFileEncoder(); - encoder.create(outputFile); - await encoder.addDirectory(stagingDirectory, includeDirName: false); - await encoder.close(); - unawaited( - AnalyticsService.instance.capture( - 'content_exported', - properties: const {'content_type': 'library'}, - ), - ); - } finally { - try { - await stagingDirectory.delete(recursive: true); - } catch (_) {} - } - } - - @visibleForTesting - Future buildFolderExportDirectoryForTest(String folderID) async { - final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); - if (folder == null) { - throw StateError("Couldn't find folder to export"); - } - - final stagingDirectory = - await Directory.systemTemp.createTemp('icarus-folder-export'); - final rootDirectory = await _createUniqueChildDirectory( - parentDirectory: stagingDirectory, - desiredName: folder.name, - ); - final exportState = _ArchiveExportState(rootDirectory: rootDirectory); - await _writeFolderArchive( - folderID: folderID, - exportDirectory: rootDirectory, - exportState: exportState, - parentManifestId: null, - currentArchivePath: '', - ); - await _writeArchiveManifest( - exportState: exportState, - archiveType: ArchiveType.folderTree, - ); - return stagingDirectory; - } - - @visibleForTesting - Future buildLibraryExportDirectoryForTest() async { - final stagingDirectory = - await Directory.systemTemp.createTemp('icarus-library-export'); - final rootDirectory = Directory( - path.join(stagingDirectory.path, libraryBackupRootDirectoryName), - ); - await rootDirectory.create(recursive: true); - final rootStrategiesDirectory = - Directory(path.join(rootDirectory.path, 'root_strategies')) - ..createSync(recursive: true); - final foldersDirectory = Directory(path.join(rootDirectory.path, 'folders')) - ..createSync(recursive: true); - - final exportState = _ArchiveExportState(rootDirectory: rootDirectory); - - for (final strategy in _sortedStrategiesForFolder(null)) { - final strategyArchivePath = await zipStrategy( - id: strategy.id, - saveDir: rootStrategiesDirectory, - ); - exportState.strategies.add( - ArchiveStrategyEntry( - name: strategy.name, - archivePath: normalizeArchivePath(path.posix.join( - 'root_strategies', - path.basename(strategyArchivePath), - )), - folderManifestId: null, - ), - ); - } - - for (final rootFolder in _sortedFoldersForParent(null)) { - final rootFolderDirectory = await _createUniqueChildDirectory( - parentDirectory: foldersDirectory, - desiredName: rootFolder.name, - ); - final rootArchivePath = normalizeArchivePath(path.posix.join( - 'folders', - path.basename(rootFolderDirectory.path), - )); - await _writeFolderArchive( - folderID: rootFolder.id, - exportDirectory: rootFolderDirectory, - exportState: exportState, - parentManifestId: null, - currentArchivePath: rootArchivePath, - ); - } - - await _writeArchiveManifest( - exportState: exportState, - archiveType: ArchiveType.libraryBackup, - globals: _buildLibraryGlobals(), - ); - return stagingDirectory; - } - - Future _createUniqueChildDirectory({ - required Directory parentDirectory, - required String desiredName, - }) async { - final sanitizedName = sanitizeFileName(desiredName); - var candidate = sanitizedName; - var counter = 1; - var directory = Directory(path.join(parentDirectory.path, candidate)); - while (await directory.exists()) { - candidate = '${sanitizedName}_$counter'; - counter++; - directory = Directory(path.join(parentDirectory.path, candidate)); - } - await directory.create(recursive: true); - return directory; - } - - Future _writeFolderArchive({ - required String folderID, - required Directory exportDirectory, - required _ArchiveExportState exportState, - required String? parentManifestId, - required String currentArchivePath, - }) async { - final currentFolder = - ref.read(folderProvider.notifier).findFolderByID(folderID); - if (currentFolder == null) { - return; - } - - final manifestId = const Uuid().v4(); - exportState.folders.add( - ArchiveFolderEntry( - manifestId: manifestId, - name: currentFolder.name, - parentManifestId: parentManifestId, - archivePath: normalizeArchivePath(currentArchivePath), - iconId: currentFolder.iconId, - color: currentFolder.color, - customColorValue: currentFolder.customColor?.toARGB32(), - ), - ); - - for (final strategy in _sortedStrategiesForFolder(folderID)) { - final strategyArchivePath = await zipStrategy( - id: strategy.id, - saveDir: exportDirectory, - ); - exportState.strategies.add( - ArchiveStrategyEntry( - name: strategy.name, - archivePath: normalizeArchivePath(path.posix.join( - currentArchivePath, - path.basename(strategyArchivePath), - )), - folderManifestId: manifestId, - ), - ); - } - - for (final subFolder in _sortedFoldersForParent(folderID)) { - final childDirectory = await _createUniqueChildDirectory( - parentDirectory: exportDirectory, - desiredName: subFolder.name, - ); - final childArchivePath = normalizeArchivePath(path.posix.join( - currentArchivePath, - path.basename(childDirectory.path), - )); - await _writeFolderArchive( - folderID: subFolder.id, - exportDirectory: childDirectory, - exportState: exportState, - parentManifestId: manifestId, - currentArchivePath: childArchivePath, - ); - } - } - - static String sanitizeFileName(String input) { - final sanitized = input.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); - return sanitized.isEmpty ? 'untitled' : sanitized; - } - - String _buildLibraryBackupFileName(DateTime timestamp) { - String twoDigit(int value) => value.toString().padLeft(2, '0'); - return 'icarus-library-backup-' - '${timestamp.year}-${twoDigit(timestamp.month)}-${twoDigit(timestamp.day)}_' - '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; - } + final updatedPages = [ + for (final page in strat.pages) + if (page.id == pageId) + page.copyWith(name: trimmed, isAutoNamed: false) + else + page, + ]; + await box.put( + strat.id, + strat.copyWith(pages: updatedPages, lastEdited: DateTime.now()), + ); + } + + /// Whether this call deleted the page. A cloud page then sits in the + /// server's trash, restorable for 30 days. A page someone else deleted + /// first lands as a no-op and returns false: this call did not delete it. + Future deletePage(String pageId) async { + if (!_currentStrategyCanEditPages()) return false; + if (_currentStrategyIsCloud()) { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.length <= 1) { + return false; + } + final pages = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + final activePageId = ref.read(strategyPageSessionProvider).activePageId ?? + pages.first.publicId; + final remaining = pages + .where((page) => page.publicId != pageId) + .toList(growable: false); + final nextActivePageId = activePageId == pageId && remaining.isNotEmpty + ? remaining.first.publicId + : activePageId; - List _sortedStrategiesForFolder(String? folderID) { - final strategies = Hive.box(HiveBoxNames.strategiesBox) - .values - .where((strategy) => strategy.folderID == folderID) - .toList(); - strategies.sort((a, b) { - final nameCompare = a.name.compareTo(b.name); - if (nameCompare != 0) { - return nameCompare; + if (activePageId == pageId) { + await ref.read(strategyPageSessionProvider.notifier).flushCurrentPage( + flushImmediately: true, + ); } - return a.id.compareTo(b.id); - }); - return strategies; - } - List _sortedFoldersForParent(String? parentID) { - final folders = Hive.box(HiveBoxNames.foldersBox) - .values - .where((folder) => folder.parentID == parentID) - .toList(); - folders.sort((a, b) { - final nameCompare = a.name.compareTo(b.name); - if (nameCompare != 0) { - return nameCompare; + final ack = await _enqueueCloudPageDescriptorOp(PageDeleteOp( + opId: const Uuid().v4(), + pagePublicId: pageId, + expectedStrategyRevision: snapshot.header.revision, + )); + if (ack?.isAck ?? false) { + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + } + if ((ack?.isAck ?? false) && nextActivePageId != activePageId) { + await ref + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + nextActivePageId, + direction: PageTransitionDirection.forward, + ); } - return a.id.compareTo(b.id); - }); - return folders; - } + return ack is AppliedOpAck; + } - ArchiveGlobals _buildLibraryGlobals() { - final profiles = Hive.box(HiveBoxNames.mapThemeProfilesBox) - .values - .map( - (profile) => ArchiveThemeProfileEntry( - id: profile.id, - name: profile.name, - palette: profile.palette, - isBuiltIn: profile.isBuiltIn, - ), - ) - .toList(growable: false); - final appPreferences = - Hive.box(HiveBoxNames.appPreferencesBox) - .get(MapThemeProfilesProvider.appPreferencesSingletonKey); - final favoriteAgents = Hive.box(HiveBoxNames.favoriteAgentsBox) - .keys - .whereType() - .toList() - ..sort(); - - return ArchiveGlobals( - themeProfiles: profiles, - defaultThemeProfileIdForNewStrategies: - appPreferences?.defaultThemeProfileIdForNewStrategies, - showSpawnBarrier: appPreferences?.showSpawnBarrier, - showUltOrbs: appPreferences?.showUltOrbs, - showRegionNames: appPreferences?.showRegionNames, - customColorValues: appPreferences?.customColorValues ?? const [], - favoriteAgents: favoriteAgents, - ); + final strategyId = state.strategyId; + if (strategyId == null) return false; + final box = Hive.box(HiveBoxNames.strategiesBox); + final strat = box.get(strategyId); + if (strat == null || strat.pages.length <= 1) return false; + + final remaining = [...strat.pages] + ..sortBySortIndex((item) => item.sortIndex) + ..removeWhere((page) => page.id == pageId); + final reindexed = reindexPagesAfterStructuralChange(remaining); + final activePageId = ref.read(strategyPageSessionProvider).activePageId; + final nextActivePageId = + activePageId == pageId ? reindexed.first.id : activePageId; + + await box.put( + strat.id, + strat.copyWith(pages: reindexed, lastEdited: DateTime.now()), + ); + if (nextActivePageId != null && nextActivePageId != activePageId) { + await ref.read(strategyProvider.notifier).setActivePageAnimated( + nextActivePageId, + ); + } + return true; } - Future _writeArchiveManifest({ - required _ArchiveExportState exportState, - required ArchiveType archiveType, - ArchiveGlobals? globals, - }) async { - final manifest = ArchiveManifest( - schemaVersion: archiveManifestSchemaVersion, - archiveType: archiveType, - exportedAt: DateTime.now().toUtc(), - appVersionNumber: Settings.versionNumber, - folders: exportState.folders, - strategies: exportState.strategies, - globals: globals, - ); + Future loadFromHive(String id) async { + _requireLocalLibrary('open'); + cancelPendingSave(); + final newStrat = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((StrategyData strategy) { + return strategy.id == id; + }).firstOrNull; - final manifestFile = File( - path.join(exportState.rootDirectory.path, archiveMetadataFileName)); - await manifestFile.writeAsString( - const JsonEncoder.withIndent(' ').convert(manifest.toJson()), - ); - } + if (newStrat == null) { + return; + } + _withoutPersistenceTracking(() { + ref.read(actionProvider.notifier).resetActionState(); + }); - MapThemePalette _resolveThemePaletteForExport(StrategyData strategy) { - if (strategy.themeOverridePalette != null) { - return strategy.themeOverridePalette!; + List pageImageData = []; + for (final page in newStrat.pages) { + pageImageData.addAll(page.imageData); + } + if (!kIsWeb) { + List allImageIds = []; + for (final page in newStrat.pages) { + allImageIds.addAll(page.imageData.map((image) => image.id)); + for (final link in page.lineUpLinks) { + allImageIds.addAll(link.images.map((image) => image.id)); + } + } + await ref + .read(placedImageProvider.notifier) + .deleteUnusedImages(newStrat.id, allImageIds); } - final profiles = - Hive.box(HiveBoxNames.mapThemeProfilesBox); - final assignedProfile = strategy.themeProfileId == null - ? null - : profiles.get(strategy.themeProfileId!); - if (assignedProfile != null) { - return assignedProfile.palette; + // We clear previous data to avoid artifacts when loading a new strategy + final migratedStrategy = StrategyMigrator.migrateToCurrentVersion(newStrat); + + if (migratedStrategy != newStrat) { + await Hive.box(HiveBoxNames.strategiesBox) + .put(migratedStrategy.id, migratedStrategy); } - return MapThemeProfilesProvider.immutableDefaultPalette; + final newDir = + kIsWeb ? null : await setStorageDirectory(migratedStrategy.id); + state = StrategyState( + strategyId: migratedStrategy.id, + strategyName: migratedStrategy.name, + source: StrategySource.local, + storageDirectory: newDir?.path, + isOpen: true, + ); + ref.read(strategySaveStateProvider.notifier).reset(); + await ref.read(strategyPageSessionProvider.notifier).initializeForStrategy( + strategyId: migratedStrategy.id, + source: StrategySource.local, + selectFirstPageIfNeeded: true, + ); + ref.read(strategySaveStateProvider.notifier).markPersisted(); } - Future zipStrategy({ - required String id, - Directory? saveDir, - String? outputFilePath, + /// Creates an empty strategy on [map] and returns it. Without [name] it + /// is auto-named after the map ("Haven", then "Haven 2", ...). In the cloud + /// workspace the strategy is created on the server and opened; nothing is + /// written to the local library. + Future createNewStrategy({ + required MapValue map, + String? name, }) async { - final strategy = Hive.box(HiveBoxNames.strategiesBox).get(id); - if (strategy == null) { - log("Couldn't find strategy to export"); - throw StateError("Couldn't find strategy to export"); - } - - final payload = { - "versionNumber": "${Settings.versionNumber}", - "mapData": "${Maps.mapNames[strategy.mapData]}", - "themePalette": _resolveThemePaletteForExport(strategy).toJson(), - if (strategy.themeProfileId != null) - "themeProfileId": strategy.themeProfileId, - "pages": strategy.pages.map((page) => page.toJson(strategy.id)).toList(), - }; - final data = jsonEncode(payload); + final box = Hive.box(HiveBoxNames.strategiesBox); + final isCloud = _selectedWorkspaceIsCloud(); + if (!isCloud) _requireLocalLibrary('create'); + final existingNames = isCloud + ? (ref.read(cloudStrategiesProvider).valueOrNull ?? const []) + .map((entry) => entry.strategy.name) + : box.values.map((strategy) => strategy.name); + final strategyName = name ?? autoStrategyName(map, existingNames); + final newID = const Uuid().v4(); + final pageID = const Uuid().v4(); + final defaultThemeProfileId = + ref.read(mapThemeProfilesProvider).defaultProfileIdForNewStrategies; + final appPreferences = ref.read(appPreferencesProvider); + final defaultSettings = StrategySettings( + agentSize: appPreferences.defaultAgentSizeForNewStrategies, + abilitySize: appPreferences.defaultAbilitySizeForNewStrategies, + useNeutralTeamColors: + appPreferences.defaultNeutralTeamColorsForNewStrategies, + ); + final newStrategy = StrategyData( + mapData: map, + versionNumber: Settings.versionNumber, + id: newID, + name: strategyName, + pages: [ + StrategyPage( + id: pageID, + name: "Page 1", + isAutoNamed: true, + drawingData: [], + agentData: [], + abilityData: [], + textData: [], + imageData: [], + utilityData: [], + sortIndex: 0, + isAttack: true, + settings: defaultSettings, + ) + ], + lastEdited: DateTime.now(), - final sanitizedStrategyName = sanitizeFileName(strategy.name); + // ignore: deprecated_member_use_from_same_package + strategySettings: defaultSettings, + folderID: ref.read(folderProvider), + themeProfileId: defaultThemeProfileId, + ); - late final String outPath; - late final String archiveBase; - if (outputFilePath != null) { - outPath = outputFilePath; - archiveBase = path.basenameWithoutExtension(outPath); - } else { - final base = sanitizedStrategyName; - var candidate = base; - var index = 1; - while (File(path.join(saveDir!.path, "$candidate.ica")).existsSync()) { - candidate = "${base}_$index"; - index++; + if (isCloud) { + try { + await ref + .read(convexStrategyRepositoryProvider) + .createStrategyWithInitialPage( + publicId: newID, + name: strategyName, + mapData: Maps.mapNames[map] ?? Maps.mapNames[MapValue.ascent]!, + initialPagePublicId: pageID, + initialPageName: "Page 1", + initialPageIsAutoNamed: true, + initialPageIsAttack: true, + initialPageSettings: defaultSettings.toJson(), + folderPublicId: ref.read(folderProvider), + themeProfileId: defaultThemeProfileId, + ); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:create_new', + error: error, + stackTrace: stackTrace, + ); + if (handled) { + throw StateError('Cloud authentication required to create strategy.'); + } + rethrow; + } + ref.invalidate(cloudStrategiesProvider); + ref.invalidate(cloudFolderTreeProvider); + try { + await openCloudStrategy(newID); + } catch (error, stackTrace) { + // The strategy exists on the server but couldn't be opened — don't + // rethrow, or the caller would falsely report that creation failed. + // Creation succeeded; opening is what failed (the editor's own load + // path surfaces that and backs out). + log( + 'Created cloud strategy $newID but failed to open it: $error', + name: 'strategy', + error: error, + stackTrace: stackTrace, + ); + Settings.showToast( + message: 'Strategy created, but it could not be opened. ' + 'Open it from your cloud library.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); } - archiveBase = candidate; - outPath = path.join(saveDir.path, "$archiveBase.ica"); + unawaited(AnalyticsService.instance.capture('strategy_created')); + return newStrategy; } - final jsonArchiveFile = - ArchiveFile.bytes("$archiveBase.json", utf8.encode(data)); - - final zipEncoder = ZipFileEncoder()..create(outPath); - - final supportDirectory = - await _getApplicationSupportDirectoryOrSystemTemp(); - final customDirectory = - Directory(path.join(supportDirectory.path, strategy.id)); - final imagesDirectory = - Directory(path.join(customDirectory.path, 'images')); - await imagesDirectory.create(recursive: true); + await box.put(newStrategy.id, newStrategy); - await for (final entity in imagesDirectory.list()) { - if (entity is File) { - await zipEncoder.addFile(entity); - } - } + unawaited(AnalyticsService.instance.capture('strategy_created')); - zipEncoder.addArchiveFile(jsonArchiveFile); - await zipEncoder.close(); - return outPath; + return newStrategy; } - Future exportFile(String id) async { - await forceSaveNow(id); + void setThemeProfileForCurrentStrategy(String profileId) { + ref.read(strategyThemeProvider.notifier).setProfile(profileId); + } - final outputFile = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: "${sanitizeFileName(state.stratName ?? "new strategy")}.ica", - allowedExtensions: ["ica"], - ); + void setThemeOverrideForCurrentStrategy(MapThemePalette palette) { + ref.read(strategyThemeProvider.notifier).setOverride(palette); + } - if (outputFile == null) return; - await zipStrategy(id: id, outputFilePath: outputFile); - unawaited( - AnalyticsService.instance.capture( - 'content_exported', - properties: const {'content_type': 'strategy'}, - ), - ); + void clearThemeOverrideForCurrentStrategy() { + ref.read(strategyThemeProvider.notifier).clearOverride(); } - Future renameStrategy(String strategyID, String newName) async { + Future renameStrategy( + String strategyID, + String newName, { + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + try { + final shell = state.strategyId == strategyID && + state.source == StrategySource.cloud + ? ref.read(remoteEditorSnapshotProvider).valueOrNull?.shell + : await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + if (shell == null) return; + await ref.read(convexStrategyRepositoryProvider).updateStrategyName( + strategyPublicId: strategyID, + name: newName, + expectedRevision: shell.header.revision, + ); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:rename', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; + } + if (state.strategyId == strategyID && + state.source == StrategySource.cloud) { + // The editor title reads the open strategy's name from this state; + // the refreshed snapshot does not feed it back. + state = state.copyWith(strategyName: newName); + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + } else { + ref.invalidate(cloudStrategiesProvider); + } + return; + } + final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); if (strategy != null) { strategy.name = newName; await strategy.save(); - if (state.id == strategyID) { - state = state.copyWith(stratName: newName); + if (state.strategyId == strategyID) { + state = state.copyWith(strategyName: newName); } } else { log("Strategy with ID $strategyID not found."); } } - Future duplicateStrategy(String strategyID) async { + Future duplicateStrategy( + String strategyID, { + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + // The server copies everything, images included, in one transaction: + // a copy's images need asset rows of their own, which only the server + // can create for bytes that are already uploaded. + const sourceName = 'strategy:duplicate'; + final reporter = ref.read(cloudLibraryActionReporterProvider); + // An image this device has not uploaded yet has nothing to copy, so + // the copy would lack it for good. The server refuses the same case + // for uploads started on other devices. + final unsentImages = ref + .read(cloudMediaUploadQueueProvider) + .pendingCountForStrategy(strategyID); + if (unsentImages > 0) { + reporter.showMessage( + "This strategy has images that haven't finished uploading. " + "Duplicate it once it's synced.", + ); + return; + } + final repository = ref.read(convexStrategyRepositoryProvider); + final result = await reporter.run( + action: () async { + final shell = await repository.fetchShell(strategyID); + try { + await repository.duplicateStrategy( + sourceStrategyPublicId: strategyID, + publicId: const Uuid().v4(), + name: "${shell.header.name} (Copy)", + folderPublicId: ref.read(folderProvider), + ); + } on Object catch (error) { + // Not a failure to retry: the copy was refused whole, so nothing + // was made. Say why. + if (!isStrategyTooLargeToDuplicateError(error)) rethrow; + reporter.showMessage( + "This strategy is too large to duplicate.", + ); + return false; + } + return true; + }, + source: sourceName, + failureMessage: "Couldn't duplicate this strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), + ); + if (result.didSucceed) { + ref.invalidate(cloudStrategiesProvider); + } + return; + } + final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final originalStrategy = strategyBox.get(strategyID); if (originalStrategy == null) { @@ -3934,8 +1421,7 @@ class StrategyProvider extends Notifier { final duplicatedStrategy = StrategyData( id: newID, name: "${originalStrategy.name} (Copy)", - mapData: originalStrategy - .mapData, // MapValue is likely an enum, so this should be safe + mapData: originalStrategy.mapData, versionNumber: originalStrategy.versionNumber, lastEdited: DateTime.now(), folderID: originalStrategy.folderID, @@ -3947,7 +1433,47 @@ class StrategyProvider extends Notifier { await strategyBox.put(duplicatedStrategy.id, duplicatedStrategy); } - Future deleteStrategy(String strategyID) async { + Future deleteStrategy( + String strategyID, { + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + const sourceName = 'strategy:delete'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).deleteStrategy( + strategyPublicId: strategyID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: "Couldn't delete this cloud strategy. Try again.", + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), + ); + if (!result.didSucceed) return result; + + // Unsent edits to a strategy the user just deleted can never land. + await ref + .read(strategyOpQueueProvider.notifier) + .discardDeletedStrategy(strategyID); + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .clearJobsForStrategy(strategyID); + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); + ref.invalidate(cloudStrategiesProvider); + return result; + } + await ref.read(pinnedItemsProvider.notifier).removePin(strategyID); await Hive.box(HiveBoxNames.strategiesBox).delete(strategyID); @@ -3955,12 +1481,19 @@ class StrategyProvider extends Notifier { final customDirectory = Directory(path.join(directory.path, strategyID)); - if (!await customDirectory.exists()) return; + if (!await customDirectory.exists()) { + return CloudLibraryActionResult.succeeded; + } await customDirectory.delete(recursive: true); + return CloudLibraryActionResult.succeeded; } Future saveToHive(String id) async { + if (_currentStrategyIsCloud()) { + return; + } + _requireLocalLibrary('save'); // final drawingData = ref.read(drawingProvider).elements; // final agentData = ref.read(agentProvider); // final abilityData = ref.read(abilityProvider); @@ -3988,23 +1521,30 @@ class StrategyProvider extends Notifier { await Hive.box(HiveBoxNames.strategiesBox) .put(currentStrategy.id, currentStrategy); - state = state.copyWith( - isSaved: true, - ); + ref.read(strategySaveStateProvider.notifier).markPersisted(); + state = state.copyWith(isSaved: true); log("Save to hive was called"); } - // Flush currently active page (uses activePageID). Safe if null/missing. + // Flush currently active page into Hive. Safe if no active page is selected. Future _syncCurrentPageToHive() async { + if (_currentStrategyIsCloud()) { + return; + } final box = Hive.box(HiveBoxNames.strategiesBox); - log("Syncing current page to hive for strategy ${state.id}"); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) { + return; + } + log("Syncing current page to hive for strategy $strategyId"); + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) { log("No strategy or pages found for syncing."); return; } - final pageId = activePageID ?? strat.pages.first.id; + final pageId = ref.read(strategyPageSessionProvider).activePageId ?? + strat.pages.first.id; final idx = strat.pages.indexWhere((p) => p.id == pageId); if (idx == -1) { log("Active page ID $pageId not found in strategy ${strat.id}"); @@ -4040,37 +1580,15 @@ class StrategyProvider extends Notifier { /// Copies current [strategySettingsProvider] marker sizes to every page in /// the open strategy (after flushing the active page to Hive). Future applyMarkerSizesToAllPages() async { - if (state.stratName == null) return; - - await _syncCurrentPageToHive(); - - final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); - if (strat == null || strat.pages.isEmpty) return; + if (state.strategyName == null) return; final target = ref.read(strategySettingsProvider); - final newPages = [ - for (final page in strat.pages) - page.copyWith( - settings: page.settings.copyWith( - agentSize: target.agentSize, - abilitySize: target.abilitySize, - ), - ), - ]; - - final strategyTheme = ref.read(strategyThemeProvider); - final updated = strat.copyWith( - pages: newPages, - mapData: ref.read(mapProvider).currentMap, - themeProfileId: strategyTheme.profileId, - clearThemeProfileId: strategyTheme.profileId == null, - themeOverridePalette: strategyTheme.overridePalette, - clearThemeOverridePalette: strategyTheme.overridePalette == null, - lastEdited: DateTime.now(), + await _applySettingsToAllPages( + (settings) => settings.copyWith( + agentSize: target.agentSize, + abilitySize: target.abilitySize, + ), ); - await box.put(updated.id, updated); - setUnsaved(); } /// Flips the side the map is drawn from. Placements are stored @@ -4088,37 +1606,160 @@ class StrategyProvider extends Notifier { final isAttack = !ref.read(mapProvider).isAttack; ref.read(mapProvider.notifier).setAttack(isAttack); setUnsaved(); - if (!allPages || state.stratName == null) return; + final strategyId = state.strategyId; + if (!allPages || strategyId == null) return; + if (!_currentStrategyCanEditPages()) return; + final activeId = ref.read(strategyPageSessionProvider).activePageId; + + if (_currentStrategyIsCloud()) { + // The active page's side reaches the server through live page sync. + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null) return; + final ops = [ + for (final page in snapshot.pages) + if (page.publicId != activeId && _intendedSide(page) != isAttack) + PagePatchOp( + opId: const Uuid().v4(), + pagePublicId: page.publicId, + payload: {'isAttack': isAttack}, + expectedPageRevision: page.revision, + ), + ]; + if (ops.isEmpty) return; + final request = Object(); + for (final op in ops) { + _requestedSides[op.pagePublicId] = + (request: request, isAttack: isAttack); + } + try { + await ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: true); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:switch_side_all_pages', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; + } finally { + _requestedSides + .removeWhere((_, entry) => identical(entry.request, request)); + } + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + return; + } final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) return; - final activeId = activePageID ?? strat.pages.first.id; + final resolvedActiveId = activeId ?? strat.pages.first.id; final updated = strat.copyWith( pages: [ for (final page in strat.pages) - page.id == activeId ? page : page.copyWith(isAttack: isAttack), + page.id == resolvedActiveId + ? page + : page.copyWith(isAttack: isAttack), ], lastEdited: DateTime.now(), ); await box.put(updated.id, updated); } + /// Side changes asked for by a switchSide call whose enqueue has not + /// finished. The op queue shows an op only after its durable write, so a + /// second toggle starting in between would otherwise read the older side. + /// Each entry is cleared by the call that set it, unless a later call has + /// replaced it. + final Map _requestedSides = {}; + + /// The side a cloud page is headed to: a side change still being queued, + /// the newest one waiting in the op queue, or the server's side. Comparing + /// against the server alone would skip a page whose queued change points + /// the other way, and that change would then win. + bool _intendedSide(RemotePage page) { + final requested = _requestedSides[page.publicId]; + if (requested != null) return requested.isAttack; + final key = EntitySyncKey.pageDescriptor(page.publicId); + final queue = ref.read(strategyOpQueueProvider); + final pendingOps = [ + queue.successorByEntityKey[key]?.pending.op, + queue.queuedByEntityKey[key]?.pending.op, + queue.pausedByEntityKey[key]?.pending.op, + queue.inFlightByEntityKey[key]?.pending.op, + ]; + for (final op in pendingOps) { + if (op is PagePatchOp && op.payload['isAttack'] is bool) { + return op.payload['isAttack'] as bool; + } + } + return page.isAttack; + } + Future applyNeutralTeamColorsToAllPages(bool value) async { - if (state.stratName == null) return; + if (state.strategyName == null) return; + + await _applySettingsToAllPages( + (settings) => settings.copyWith(useNeutralTeamColors: value), + ); + } + + Future _applySettingsToAllPages( + StrategySettings Function(StrategySettings settings) transform, + ) async { + if (!_currentStrategyCanEditPages()) return; + if (_currentStrategyIsCloud()) { + final strategyId = state.strategyId; + if (strategyId == null) { + return; + } + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchFullSnapshot(strategyId); + if (snapshot.pages.isEmpty) return; + + final ops = [ + for (final fullPage in snapshot.pages) + PageContentPatchOp( + opId: const Uuid().v4(), + pagePublicId: fullPage.page.publicId, + settings: transform(_settingsFromPayloadOrDefault( + fullPage.content.settings, + )).toJson(), + expectedPageContentRevision: fullPage.content.revision, + ), + ]; + + try { + await ref + .read(strategyOpQueueProvider.notifier) + .enqueueAll(ops, flushImmediately: true); + } catch (error, stackTrace) { + final handled = await _reportCloudUnauthenticated( + source: 'strategy:apply_settings_to_all_pages', + error: error, + stackTrace: stackTrace, + ); + if (!handled) rethrow; + return; + } + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + return; + } await _syncCurrentPageToHive(); final box = Hive.box(HiveBoxNames.strategiesBox); - final strat = box.get(state.id); + final strategyId = state.strategyId; + if (strategyId == null) return; + final strat = box.get(strategyId); if (strat == null || strat.pages.isEmpty) return; final newPages = [ for (final page in strat.pages) - page.copyWith( - settings: page.settings.copyWith(useNeutralTeamColors: value), - ), + page.copyWith(settings: transform(page.settings)), ]; final strategyTheme = ref.read(strategyThemeProvider); @@ -4135,15 +1776,66 @@ class StrategyProvider extends Notifier { setUnsaved(); } - void moveToFolder({required String strategyID, required String? parentID}) { + StrategySettings _settingsFromPayloadOrDefault(Object? payload) { + if (payload == null) { + return StrategySettings(); + } + try { + final decoded = cloudObjectPayloadOrNull(payload); + if (decoded != null) { + return StrategySettings.fromJson(decoded); + } + } catch (_) {} + return StrategySettings(); + } + + Future moveToFolder({ + required String strategyID, + required String? parentID, + StrategySource? source, + }) async { + final resolvedSource = source ?? _resolveLibraryMutationSource(); + if (resolvedSource == StrategySource.cloud) { + const sourceName = 'strategy:move'; + final result = await ref.read(cloudLibraryActionReporterProvider).run( + action: () async { + final shell = await ref + .read(convexStrategyRepositoryProvider) + .fetchShell(strategyID); + await ref.read(convexStrategyRepositoryProvider).moveStrategy( + strategyPublicId: strategyID, + folderPublicId: parentID, + expectedRevision: shell.header.revision, + ); + return true; + }, + source: sourceName, + failureMessage: "Couldn't move this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: sourceName, + error: error, + stackTrace: stackTrace, + ), + ); + if (!result.didSucceed) return result; + + ref.invalidate(cloudStrategiesProvider); + return result; + } final strategyBox = Hive.box(HiveBoxNames.strategiesBox); final strategy = strategyBox.get(strategyID); if (strategy != null) { strategy.folderID = parentID; - strategy.save(); + await strategy.save(); + return CloudLibraryActionResult.succeeded; } else { log("Strategy with ID $strategyID not found."); + return CloudLibraryActionResult.failed( + "Couldn't move this strategy. Try again.", + ); } } } diff --git a/lib/providers/strategy_save_state_provider.dart b/lib/providers/strategy_save_state_provider.dart new file mode 100644 index 00000000..2f7b70ea --- /dev/null +++ b/lib/providers/strategy_save_state_provider.dart @@ -0,0 +1,195 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class StrategySaveState { + const StrategySaveState({ + required this.isDirty, + required this.isSaving, + required this.hasPendingCloudSync, + required this.cloudSyncError, + required this.hasPendingMediaSync, + required this.mediaSyncErrorCount, + required this.lastPersistedAt, + }); + + final bool isDirty; + final bool isSaving; + final bool hasPendingCloudSync; + final String? cloudSyncError; + final bool hasPendingMediaSync; + final int mediaSyncErrorCount; + final DateTime? lastPersistedAt; + + bool get canLeaveSafely => + !isDirty && + !isSaving && + !hasPendingCloudSync && + !hasPendingMediaSync && + cloudSyncError == null && + mediaSyncErrorCount == 0; + + StrategySaveState copyWith({ + bool? isDirty, + bool? isSaving, + bool? hasPendingCloudSync, + String? cloudSyncError, + bool? hasPendingMediaSync, + int? mediaSyncErrorCount, + bool clearCloudSyncError = false, + DateTime? lastPersistedAt, + }) { + return StrategySaveState( + isDirty: isDirty ?? this.isDirty, + isSaving: isSaving ?? this.isSaving, + hasPendingCloudSync: hasPendingCloudSync ?? this.hasPendingCloudSync, + cloudSyncError: + clearCloudSyncError ? null : (cloudSyncError ?? this.cloudSyncError), + hasPendingMediaSync: hasPendingMediaSync ?? this.hasPendingMediaSync, + mediaSyncErrorCount: mediaSyncErrorCount ?? this.mediaSyncErrorCount, + lastPersistedAt: lastPersistedAt ?? this.lastPersistedAt, + ); + } +} + +final strategySaveStateProvider = + NotifierProvider( + StrategySaveStateNotifier.new, +); + +class StrategySaveStateNotifier extends Notifier { + @override + StrategySaveState build() { + ref.listen(strategyOpQueueProvider, (previous, next) { + final source = ref.read(strategyProvider).source; + if (source != StrategySource.cloud) { + return; + } + + final hasPendingSync = next.isFlushing || next.pending.isNotEmpty; + state = state.copyWith( + isSaving: next.isFlushing, + hasPendingCloudSync: hasPendingSync, + cloudSyncError: next.lastError, + clearCloudSyncError: next.lastError == null, + isDirty: hasPendingSync ? true : state.isDirty, + ); + + if (!hasPendingSync && next.lastError == null) { + state = state.copyWith( + isDirty: false, + lastPersistedAt: DateTime.now(), + ); + } + }); + + ref.listen(cloudMediaUploadQueueProvider, ( + previous, + next, + ) { + final source = ref.read(strategyProvider).source; + if (source != StrategySource.cloud) { + return; + } + + final strategyId = ref.read(strategyProvider).strategyId; + final strategyJobs = next.jobsForStrategy(strategyId); + final failedJobs = strategyJobs.where((job) => job.isFailed).length; + final hasPendingMedia = strategyJobs.isNotEmpty; + final hasPendingCloudSync = state.hasPendingCloudSync || hasPendingMedia; + state = state.copyWith( + hasPendingMediaSync: hasPendingMedia, + mediaSyncErrorCount: failedJobs, + isDirty: hasPendingCloudSync ? true : state.isDirty, + ); + + if (!hasPendingCloudSync && + state.cloudSyncError == null && + failedJobs == 0) { + state = state.copyWith( + isDirty: false, + lastPersistedAt: DateTime.now(), + ); + } + }); + + return const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ); + } + + void reset() { + state = const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ); + } + + void markDirty() { + state = state.copyWith( + isDirty: true, + clearCloudSyncError: true, + ); + } + + void markSaving(bool value) { + state = state.copyWith(isSaving: value); + } + + void setPendingCloudSync(bool value) { + state = state.copyWith(hasPendingCloudSync: value); + } + + void setCloudSyncError(String? error) { + state = state.copyWith( + cloudSyncError: error, + clearCloudSyncError: error == null, + ); + } + + /// Clears an unsaved mark no op stands behind. Edits to a page the server + /// no longer has leave one: they produce no op, so the queue never changes + /// to clear it. + void clearStaleCloudMark() { + final queue = ref.read(strategyOpQueueProvider); + if (!state.isDirty || + state.hasPendingMediaSync || + queue.isFlushing || + queue.pending.isNotEmpty || + queue.lastError != null) { + return; + } + state = state.copyWith( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + clearCloudSyncError: true, + lastPersistedAt: DateTime.now(), + ); + } + + void markPersisted() { + state = state.copyWith( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + clearCloudSyncError: true, + lastPersistedAt: DateTime.now(), + ); + } +} diff --git a/lib/providers/text_provider.dart b/lib/providers/text_provider.dart index 92ec180f..8279054a 100644 --- a/lib/providers/text_provider.dart +++ b/lib/providers/text_provider.dart @@ -1,24 +1,16 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; import 'package:icarus/providers/text_draft_provider.dart'; final textProvider = NotifierProvider>(TextProvider.new); -class TextProviderSnapshot { - final List texts; - final List poppedText; - - const TextProviderSnapshot({ - required this.texts, - required this.poppedText, - }); -} - class TextProvider extends Notifier> { static final double _legacyWidthToWorldFactor = (1000.0 * (16 / 9)) / CoordinateSystem.screenShotSize.width; @@ -47,6 +39,9 @@ class TextProvider extends Notifier> { type: ActionType.addition, id: text.id, group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.text(text), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -55,13 +50,17 @@ class TextProvider extends Notifier> { } void removeTextAsAction(String id) { - if (!state.any((text) => text.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.text(state[index]), + ), ), ); removeText(id); @@ -73,14 +72,22 @@ class TextProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.text(newState[index]); newState[index].updatePosition(position); //Moving foward final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.text); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.text(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -93,9 +100,18 @@ class TextProvider extends Notifier> { if (newState[index].text == nextText) return; + final before = ActionObjectState.text(newState[index]); newState[index].commitText(nextText); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.text), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.text, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.text(newState[index]), + ), + ), ); state = newState; } @@ -110,25 +126,41 @@ class TextProvider extends Notifier> { } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeText(action.id); + return; + case ActionType.deletion: + if (poppedText.isEmpty) return; + _upsertText(clonePlacedText(poppedText.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeText(action.id); + return; case ActionType.deletion: - if (poppedText.isEmpty) return; - - final newState = [...state]; - - newState.add(poppedText.removeLast()); - state = newState; + final before = delta.before?.text; + if (before == null) return; + _upsertText(clonePlacedText(before)); + return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - - newState[index].undoAction(); - - state = newState; + _writeEdit(action.id, delta.undoOnto); + return; case ActionType.bulkDeletion: case ActionType.transaction: return; @@ -136,29 +168,44 @@ class TextProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedText); - newState.add(poppedText.removeAt(index)); - + if (poppedText.isEmpty) return; + _upsertText(clonePlacedText(poppedText.removeLast())); + return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedText); - - poppedText.add(newState.removeAt(index)); - + removeText(action.id); + return; case ActionType.edit: - final index = PlacedWidget.getIndexByID(action.id, newState); - + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; newState[index].redoAction(); + state = newState; + return; case ActionType.bulkDeletion: case ActionType.transaction: return; } - } catch (_) {} - state = newState; + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.text; + if (after == null) return; + _upsertText(clonePlacedText(after)); + return; + case ActionType.deletion: + removeText(action.id); + return; + case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } /// Sets a text's width and position together: on defense a resize moves @@ -186,8 +233,8 @@ class TextProvider extends Notifier> { text.text = draft; ref.read(textDraftProvider.notifier).clearDraft(id); } - poppedText.add(text); - + poppedText.removeWhere((item) => item.id == id); + poppedText.add(clonePlacedText(text)); state = newState; } @@ -234,22 +281,43 @@ class TextProvider extends Notifier> { return jsonEncode(jsonList); } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + /// Open drafts stay open. + void mergeRemote(List incoming, bool Function(String id) keep) { + state = mergeRemoteItems( + current: state, + incoming: incoming.map(_migrateLoadedText).toList(), + idOf: (text) => text.id, + keep: keep, + ); + } + void clearAll() { ref.read(textDraftProvider.notifier).clearAllDrafts(); poppedText = []; state = []; } - TextProviderSnapshot takeSnapshot() { - return TextProviderSnapshot( - texts: [...state], - poppedText: [...poppedText], - ); + /// Writes an edit onto the text as it is now. A text that is gone + /// (a teammate deleted it) stays gone. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; + _upsertText(write(ActionObjectState.text(state[index])).text!); } - void restoreSnapshot(TextProviderSnapshot snapshot) { - ref.read(textDraftProvider.notifier).clearAllDrafts(); - poppedText = [...snapshot.poppedText]; - state = [...snapshot.texts]; + void _upsertText(PlacedText text) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(text.id, newState); + if (index < 0) { + newState.add(text); + } else { + newState[index] = text; + } + state = newState; } } diff --git a/lib/providers/text_widget_height_provider.dart b/lib/providers/text_widget_height_provider.dart index 91896068..2656b7ae 100644 --- a/lib/providers/text_widget_height_provider.dart +++ b/lib/providers/text_widget_height_provider.dart @@ -21,13 +21,6 @@ class TextWidgetHeightProvider extends Notifier> { return state[id] ?? Offset.zero; } - Map takeSnapshotForIds(Iterable ids) { - return { - for (final id in ids) - if (state.containsKey(id)) id: state[id]!, - }; - } - void clearEntries(Iterable ids) { final newState = {...state}; for (final id in ids) { @@ -36,13 +29,6 @@ class TextWidgetHeightProvider extends Notifier> { state = newState; } - void restoreSnapshot(Map snapshot) { - state = { - ...state, - ...snapshot, - }; - } - void clearAll() { state = {}; } diff --git a/lib/providers/update_status_provider.dart b/lib/providers/update_status_provider.dart index 071495bb..cfec59df 100644 --- a/lib/providers/update_status_provider.dart +++ b/lib/providers/update_status_provider.dart @@ -1,6 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; final appUpdateStatusProvider = FutureProvider((ref) async { + // Checked again when the server refuses this build: the update it needs + // may have shipped since the check at launch. + ref.watch(clientUpgradeRequiredProvider); return UpdateChecker.checkForWindowsStoreUpdateSignal(); }); diff --git a/lib/providers/user_preferences_provider.dart b/lib/providers/user_preferences_provider.dart index e6baf463..a3542ac9 100644 --- a/lib/providers/user_preferences_provider.dart +++ b/lib/providers/user_preferences_provider.dart @@ -720,6 +720,12 @@ class AppPreferencesNotifier extends Notifier { } AppPreferences _readFromHive() { + if (!Hive.isBoxOpen(HiveBoxNames.appPreferencesBox)) { + return AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); + } return Hive.box(HiveBoxNames.appPreferencesBox).get( MapThemeProfilesProvider.appPreferencesSingletonKey, ) ?? diff --git a/lib/providers/utility_provider.dart b/lib/providers/utility_provider.dart index 562b17da..3829ce53 100644 --- a/lib/providers/utility_provider.dart +++ b/lib/providers/utility_provider.dart @@ -2,9 +2,11 @@ import 'dart:convert'; import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/utilities.dart'; import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; final utilityProvider = NotifierProvider>(UtilityProvider.new); @@ -21,6 +23,7 @@ class UtilityProviderSnapshot { class UtilityProvider extends Notifier> { List poppedUtilities = []; + final Map _pendingEditBefore = {}; @override List build() { @@ -32,6 +35,9 @@ class UtilityProvider extends Notifier> { type: ActionType.addition, id: utility.id, group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + after: ActionObjectState.utility(utility), + ), ); ref.read(actionProvider.notifier).addAction(action); @@ -39,13 +45,17 @@ class UtilityProvider extends Notifier> { } void removeUtilityAsAction(String id) { - if (!state.any((utility) => utility.id == id)) return; + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; ref.read(actionProvider.notifier).addAction( UserAction( type: ActionType.deletion, id: id, group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: ActionObjectState.utility(state[index]), + ), ), ); removeUtility(id); @@ -55,11 +65,19 @@ class UtilityProvider extends Notifier> { final newState = [...state]; final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; + final before = ActionObjectState.utility(newState[index]); newState[index].updatePosition(position); final temp = newState.removeAt(index); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(temp), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = [...newState, temp]; @@ -67,12 +85,18 @@ class UtilityProvider extends Notifier> { void updateRotation(int index, double rotation, double length) { final newState = [...state]; - updateRotationHistory(index); + final before = _pendingEditBefore.remove(newState[index].id) ?? + ActionObjectState.utility(newState[index]); newState[index].updateRotation(rotation, length); final action = UserAction( - type: ActionType.edit, - id: newState[index].id, - group: ActionGroup.utility); + type: ActionType.edit, + id: newState[index].id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(newState[index]), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } @@ -87,10 +111,18 @@ class UtilityProvider extends Notifier> { return; } - utility.updateRotationHistory(); + final before = ActionObjectState.utility(utility); utility.updateVisionElevation(elevation); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(utility), + ), + ), ); state = newState; } @@ -137,6 +169,7 @@ class UtilityProvider extends Notifier> { utility.customLength != nextLength; if (!hasGeometryChange) return; + final before = ActionObjectState.utility(utility); utility.updateCustomShapeGeometry( newPosition: position, newDiameter: diameterMeters, @@ -144,8 +177,15 @@ class UtilityProvider extends Notifier> { newLength: lengthMeters, ); - final action = - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility); + final action = UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(utility), + ), + ); ref.read(actionProvider.notifier).addAction(action); state = newState; } @@ -161,46 +201,64 @@ class UtilityProvider extends Notifier> { return; } + final before = ActionObjectState.utility(utility); utility.updateCustomShapeColor(colorValue); ref.read(actionProvider.notifier).addAction( - UserAction(type: ActionType.edit, id: id, group: ActionGroup.utility), + UserAction( + type: ActionType.edit, + id: id, + group: ActionGroup.utility, + objectDelta: ObjectHistoryDelta( + before: before, + after: ActionObjectState.utility(utility), + ), + ), ); state = newState; } void updateRotationHistory(int index) { - final newState = [...state]; - - newState[index].updateRotationHistory(); - - state = newState; + if (index < 0 || index >= state.length) return; + _pendingEditBefore[state[index].id] = + ActionObjectState.utility(state[index]); } void undoAction(UserAction action) { + final delta = action.objectDelta; + if (delta == null) { + switch (action.type) { + case ActionType.addition: + removeUtility(action.id); + return; + case ActionType.deletion: + if (poppedUtilities.isEmpty) return; + _upsertUtility(clonePlacedUtility(poppedUtilities.removeLast())); + return; + case ActionType.edit: + final index = PlacedWidget.getIndexByID(action.id, state); + if (index < 0) return; + final newState = [...state]; + newState[index].undoAction(); + state = newState; + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } + } switch (action.type) { case ActionType.addition: removeUtility(action.id); return; case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, poppedUtilities); - if (index < 0) { + final before = delta.before?.utility; + if (before == null) { return; } - - final newState = [...state]; - - final restoredUtility = poppedUtilities.removeAt(index); - newState.add(restoredUtility); - state = newState; + _upsertUtility(clonePlacedUtility(before)); return; case ActionType.edit: - final newState = [...state]; - - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - - newState[index].undoAction(); - state = newState; + _writeEdit(action.id, delta.undoOnto); return; case ActionType.bulkDeletion: case ActionType.transaction: @@ -209,23 +267,16 @@ class UtilityProvider extends Notifier> { } void redoAction(UserAction action) { - final newState = [...state]; - - try { + final delta = action.objectDelta; + if (delta == null) { + final newState = [...state]; switch (action.type) { case ActionType.addition: - final index = PlacedWidget.getIndexByID(action.id, poppedUtilities); - if (index < 0) return; - final restoredUtility = poppedUtilities.removeAt(index); - newState.add(restoredUtility); - state = newState; + if (poppedUtilities.isEmpty) return; + _upsertUtility(clonePlacedUtility(poppedUtilities.removeLast())); return; - case ActionType.deletion: - final index = PlacedWidget.getIndexByID(action.id, newState); - if (index < 0) return; - poppedUtilities.add(newState.removeAt(index)); - state = newState; + removeUtility(action.id); return; case ActionType.edit: final index = PlacedWidget.getIndexByID(action.id, newState); @@ -237,7 +288,23 @@ class UtilityProvider extends Notifier> { case ActionType.transaction: return; } - } catch (_) {} + } + switch (action.type) { + case ActionType.addition: + final after = delta.after?.utility; + if (after == null) return; + _upsertUtility(clonePlacedUtility(after)); + return; + case ActionType.deletion: + removeUtility(action.id); + return; + case ActionType.edit: + _writeEdit(action.id, delta.redoOnto); + return; + case ActionType.bulkDeletion: + case ActionType.transaction: + return; + } } void removeUtility(String id) { @@ -246,19 +313,34 @@ class UtilityProvider extends Notifier> { final index = PlacedWidget.getIndexByID(id, newState); if (index < 0) return; - final ability = newState.removeAt(index); - poppedUtilities.add(ability); + final removedUtility = newState.removeAt(index); + poppedUtilities.removeWhere((utility) => utility.id == id); + poppedUtilities.add(clonePlacedUtility(removedUtility)); state = newState; } void fromHive(List hiveUtilities) { poppedUtilities = []; + _pendingEditBefore.clear(); state = hiveUtilities; } + /// Takes the server's copy of every item but those [keep] names; see + /// [mergeRemoteItems]. + void mergeRemote( + List incoming, bool Function(String id) keep) { + state = mergeRemoteItems( + current: state, + incoming: incoming, + idOf: (utility) => utility.id, + keep: keep, + ); + } + void clearAll() { poppedUtilities = []; + _pendingEditBefore.clear(); state = []; } @@ -302,8 +384,31 @@ class UtilityProvider extends Notifier> { poppedUtilities = snapshot.poppedUtilities .map((utility) => utility.snapshotCopy()) .toList(); + _pendingEditBefore.clear(); state = snapshot.utilities .map((utility) => utility.snapshotCopy()) .toList(); } + + /// Writes an edit onto the utility as it is now. A utility that is gone + /// (a teammate deleted it) stays gone. + void _writeEdit( + String id, + ActionObjectState Function(ActionObjectState current) write, + ) { + final index = PlacedWidget.getIndexByID(id, state); + if (index < 0) return; + _upsertUtility(write(ActionObjectState.utility(state[index])).utility!); + } + + void _upsertUtility(PlacedUtility utility) { + final newState = [...state]; + final index = PlacedWidget.getIndexByID(utility.id, newState); + if (index < 0) { + newState.add(utility); + } else { + newState[index] = utility; + } + state = newState; + } } diff --git a/lib/screenshot/capture_images.dart b/lib/screenshot/capture_images.dart new file mode 100644 index 00000000..5337fed8 --- /dev/null +++ b/lib/screenshot/capture_images.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; +import 'package:http/http.dart' as http; +import 'package:icarus/providers/strategy_image_source.dart'; + +/// An image a capture would paint is not ready: its cloud copy is still +/// loading, or its bytes could not be fetched or decoded. The capture stops +/// rather than save a picture with the image missing. +class CaptureImagesUnavailable implements Exception { + const CaptureImagesUnavailable.stillLoading() : cause = null; + const CaptureImagesUnavailable.fetchFailed(this.cause); + + /// Why the image could not be loaded; null while it is still loading. + final Object? cause; + + String get userMessage => cause == null + ? 'Images on this page are still loading. Try again in a moment.' + : "Couldn't load the images on this page. Check your connection and " + 'try again.'; + + @override + String toString() => cause == null + ? 'CaptureImagesUnavailable: an image is still loading' + : 'CaptureImagesUnavailable: $cause'; +} + +/// Fetches the bytes behind a cloud image's [url] through [client], the +/// capture's own. Call `client.get`, never package:http's top-level `get`: +/// that one closes whatever client it picked up when it finishes. +typedef CaptureImageFetcher = Future Function( + String imageId, + String url, + http.Client client, +); + +/// What each image in a capture paints, by image id, decoded and held in +/// the image cache so the capture's first frame already has every picture. +/// Call [release] once the capture is done. +class CaptureImages { + CaptureImages._(this.sources, this._holds); + + /// Feeds [captureImageSourcesProvider] in the capture's container. + final Map sources; + final List<_HeldImage> _holds; + + void release() { + for (final hold in _holds) { + hold.release(); + } + _holds.clear(); + } +} + +/// Turns where each image's bytes come from, by image id, into what an +/// offscreen capture can paint. +/// +/// A capture has no network reads of its own, so cloud URLs are fetched here +/// and painted from memory. Files and bytes already in memory paint as they +/// are, and an image the editor shows as unavailable is captured that way +/// too. Every image that paints is decoded before this returns. Throws +/// [CaptureImagesUnavailable] while an image is still loading, or when a +/// fetch or a decode fails. [checkpoint] runs before each image; whatever it +/// throws stops the work and releases what was held. +Future resolveCaptureImages( + Map sources, { + required CaptureImageFetcher fetch, + void Function()? checkpoint, +}) async { + if (sources.values.any((source) => source is ImageLoading)) { + throw const CaptureImagesUnavailable.stillLoading(); + } + // Downloads run together, so a page's wait is its slowest image rather + // than the sum of them. They share one client, closed only when this + // returns, so a capture that stops early aborts the downloads it no + // longer needs; a download nobody awaits must not surface as an + // unhandled error. + final client = http.Client(); + final downloads = { + for (final MapEntry(key: imageId, value: source) in sources.entries) + if (source case RemoteImageUrl(:final url)) + imageId: _guard(() => fetch(imageId, url, client))..ignore(), + }; + final resolved = {}; + final holds = <_HeldImage>[]; + try { + for (final MapEntry(key: imageId, value: source) in sources.entries) { + checkpoint?.call(); + final download = downloads[imageId]; + final paintable = download == null ? source : ImageBytes(await download); + final image = paintable.imageProvider; + if (image != null) { + holds.add(await _guard(() => _HeldImage.decode(image))); + } + resolved[imageId] = paintable; + } + checkpoint?.call(); + } catch (_) { + for (final hold in holds) { + hold.release(); + } + rethrow; + } finally { + client.close(); + } + return CaptureImages._(resolved, holds); +} + +Future _guard(Future Function() load) async { + try { + return await load(); + } catch (error) { + throw CaptureImagesUnavailable.fetchFailed(error); + } +} + +/// A decoded image kept listened to, which keeps it among the image cache's +/// live images: a widget asking for the same image finds it decoded, however +/// full the cache gets, until [release]. +class _HeldImage { + _HeldImage._(this._stream); + + final ImageStream _stream; + late final ImageStreamListener _listener; + bool _attached = false; + + /// Completes once [image]'s first frame is decoded. + static Future<_HeldImage> decode(ImageProvider image) { + final hold = _HeldImage._(image.resolve(ImageConfiguration.empty)); + final decoded = Completer<_HeldImage>(); + hold._listener = ImageStreamListener( + (info, _) { + info.dispose(); + if (!decoded.isCompleted) decoded.complete(hold); + }, + onError: (Object error, StackTrace? stackTrace) { + hold.release(); + if (!decoded.isCompleted) decoded.completeError(error, stackTrace); + }, + ); + hold._attached = true; + hold._stream.addListener(hold._listener); + return decoded.future; + } + + /// Lets go of the image. Safe to call again: a later frame that fails to + /// decode lets go first, and the stream may be gone by the second call. + void release() { + if (!_attached) return; + _attached = false; + _stream.removeListener(_listener); + } +} diff --git a/lib/screenshot/offscreen_capture.dart b/lib/screenshot/offscreen_capture.dart index c6c03fdd..73f6032f 100644 --- a/lib/screenshot/offscreen_capture.dart +++ b/lib/screenshot/offscreen_capture.dart @@ -3,8 +3,34 @@ import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +/// The provider container one offscreen capture renders in, cut off from the +/// editor's, with [images] as what its images paint. +/// +/// A capture's providers are built fresh here, and a real auth provider +/// among them would reach the one Convex client the app syncs through: +/// building it sets or clears that client's auth, and disposing it tears +/// the auth down, signing the editor out of sync. The strategy provider +/// listens to auth, so auth here is an inert signed-out state; nothing a +/// capture paints needs more. +ProviderContainer createCaptureContainer({ + Map images = const {}, +}) => + ProviderContainer( + overrides: [ + authProvider.overrideWith(_CaptureAuth.new), + captureImageSourcesProvider.overrideWithValue(images), + ], + ); + +class _CaptureAuth extends AuthProvider { + @override + AppAuthState build() => AppAuthState.fromSession(null); +} + /// Keep screenshot coordinates confined to pixel capture. Asset loading and /// output dialogs must leave the live editor's coordinate conversions intact. Future withScreenshotCoordinates(Future Function() capture) async { diff --git a/lib/screenshot/page_screenshot.dart b/lib/screenshot/page_screenshot.dart new file mode 100644 index 00000000..8f0d7132 --- /dev/null +++ b/lib/screenshot/page_screenshot.dart @@ -0,0 +1,183 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_history_models.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/screenshot/capture_geometry.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:icarus/screenshot/offscreen_capture.dart'; +import 'package:icarus/screenshot/persistent_offscreen_renderer.dart'; +import 'package:icarus/screenshot/screenshot_view.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +/// Renders the page open in the editor, as it is on screen, to a PNG. +/// +/// The capture reads the editor's live state rather than a saved copy, so a +/// cloud strategy captures exactly like a local one and nothing waits on a +/// save or a sync. Throws [CaptureImagesUnavailable] rather than return a +/// picture with an image missing. +Future captureEditorPage(WidgetRef ref) async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyId == null) { + throw StateError('No strategy is open to capture.'); + } + final page = editorPageSnapshot(ref); + final mapState = ref.read(mapProvider); + final theme = ref.read(strategyThemeProvider); + + final images = await resolveCaptureImages( + { + for (final image in page.imageData) + image.id: readStrategyImageSource( + ref, + (id: image.id, fileExtension: image.fileExtension), + ), + }, + fetch: (imageId, url, client) => downloadCloudImageBytes( + url, + client: client, + freshUrl: () { + final linkView = ref.read(shareLinkViewProvider); + return ref.read(convexStrategyRepositoryProvider).getImageAssetUrl( + strategyPublicId: strategyId, + assetPublicId: imageId, + // A signed-out reader's only access is the link they opened. + shareToken: linkView?.strategyPublicId == strategyId + ? linkView!.token + : null, + ); + }, + ), + ); + + final view = ScreenshotView( + isAttack: page.isAttack, + mapValue: mapState.currentMap, + showSpawnBarrier: mapState.showSpawnBarrier, + showRegionNames: mapState.showRegionNames, + showUltOrbs: mapState.showUltOrbs, + agents: page.agentData, + abilities: page.abilityData, + text: page.textData, + images: page.imageData, + drawings: page.drawingData, + utilities: page.utilityData, + strategySettings: page.settings, + strategyState: strategyState, + pageName: page.name, + lineUpGraph: page.lineUpGraph, + themeProfileId: theme.profileId, + themeOverridePalette: theme.overridePalette, + ); + + final container = createCaptureContainer(images: images.sources); + CaptureGeometryLease? geometry; + try { + // The sightline models load asynchronously; the capture waits for the + // page's geometry before the first frame is taken. + geometry = await prepareCaptureGeometry( + container, + mapState.currentMap, + [page], + ); + final waitForFrame = geometry?.waitForFrame; + return await withScreenshotCoordinates(() async { + view.hydrateProviders(container); + final renderer = PersistentOffscreenRenderer( + targetSize: CoordinateSystem.screenShotSize, + waitForFrameData: waitForFrame, + wrapWidget: (child) => + wrapForOffscreenCapture(child, container: container), + ); + try { + await renderer.prepare( + view, + settleDuration: const Duration(milliseconds: 800), + ); + return await renderer.capture(view); + } finally { + await renderer.dispose(); + } + }); + } finally { + geometry?.close(); + container.dispose(); + images.release(); + } +} + +/// A copy of the page on the canvas right now. The editor keeps editing +/// its own objects in place while the capture fetches images, and a capture +/// rebuilds drawing paths in screenshot coordinates; neither may reach the +/// other. +@visibleForTesting +StrategyPage editorPageSnapshot(WidgetRef ref) { + final lineUps = ref.read(lineUpProvider).graph.deepCopy(); + return StrategyPage( + id: ref.read(strategyPageSessionProvider).activePageId ?? '', + name: _activePageName(ref) ?? '', + drawingData: DrawingProvider.fromJson( + DrawingProvider.objectToJson(ref.read(drawingProvider).elements), + ), + agentData: ref.read(agentProvider).map(clonePlacedAgentNode).toList(), + abilityData: ref.read(abilityProvider).map(clonePlacedAbility).toList(), + textData: ref + .read(textProvider.notifier) + .snapshotForPersistence() + .map(clonePlacedText) + .toList(), + imageData: + ref.read(placedImageProvider).images.map(clonePlacedImage).toList(), + utilityData: ref.read(utilityProvider).map(clonePlacedUtility).toList(), + sortIndex: 0, + isAttack: ref.read(mapProvider).isAttack, + settings: ref.read(strategySettingsProvider), + lineUpOrigins: lineUps.origins, + lineUpLandings: lineUps.landings, + lineUpLinks: lineUps.links, + ); +} + +/// The open page's name, from wherever its strategy lives, as the pages bar +/// shows it. +String? _activePageName(WidgetRef ref) { + final strategy = ref.read(strategyProvider); + final pageId = ref.read(strategyPageSessionProvider).activePageId; + if (pageId == null) return null; + return switch (strategy.source) { + StrategySource.cloud => ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.pages + .where((page) => page.publicId == pageId) + .firstOrNull + ?.name, + StrategySource.local => Hive.box(HiveBoxNames.strategiesBox) + .get(strategy.strategyId) + ?.pages + .where((page) => page.id == pageId) + .firstOrNull + ?.name, + null => null, + }; +} diff --git a/lib/screenshot/screenshot_view.dart b/lib/screenshot/screenshot_view.dart index 49238657..eb77e427 100644 --- a/lib/screenshot/screenshot_view.dart +++ b/lib/screenshot/screenshot_view.dart @@ -79,7 +79,6 @@ class ScreenshotView extends ConsumerWidget { /// surface that assertion. void hydrateProviders(ProviderContainer container) { CoordinateSystem.instance.setIsScreenshot(true); - container.read(strategyProvider.notifier).setFromState(strategyState); container.read(agentProvider.notifier).fromHive(agents); container.read(screenshotProvider.notifier).setIsScreenShot(true); container.read(abilityProvider.notifier).fromHive(abilities); @@ -99,6 +98,9 @@ class ScreenshotView extends ConsumerWidget { container .read(drawingProvider.notifier) .rebuildAllPaths(CoordinateSystem.instance); + // Keep the strategy closed while its dependent providers are hydrated so + // their listeners cannot mistake capture setup for user edits. + container.read(strategyProvider.notifier).setFromState(strategyState); } @override diff --git a/lib/services/app_error_reporter.dart b/lib/services/app_error_reporter.dart index ea8b3ed0..ce403293 100644 --- a/lib/services/app_error_reporter.dart +++ b/lib/services/app_error_reporter.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:developer' as developer; import 'dart:io'; +import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/settings.dart'; @@ -17,6 +18,12 @@ class AppErrorReporter { minutes: 2, ); + /// Whether warnings and errors are also printed to the console. On web, + /// `developer.log` does not reach the browser console in a release build, + /// so without this a failure there leaves no trace. Messages are already + /// redacted by their callers, as they are for the copyable debug report. + static bool echoToConsole = kIsWeb; + static bool _isDebugDialogOpen = false; static File? _persistedLogFile; static String? _applicationSupportDirectoryPath; @@ -143,8 +150,18 @@ class AppErrorReporter { stackTrace: stackTrace, level: _developerLogLevel(level), ); + if (echoToConsole && level != DebugLogLevel.info) { + debugPrint( + '[${source ?? 'Icarus'}] ${level.name}: $message' + '${errorText == null ? '' : ': $errorText'}', + ); + } - appProviderContainer.read(inAppDebugProvider.notifier).addEntry(entry); + try { + appProviderContainer.read(inAppDebugProvider.notifier).addEntry(entry); + } catch (_) { + // Tests and early startup can report logs before the global container exists. + } _queuePersistedLogWrite(entry); return entry; } diff --git a/lib/services/archive_manifest.dart b/lib/services/archive_manifest.dart index 58765703..9f236307 100644 --- a/lib/services/archive_manifest.dart +++ b/lib/services/archive_manifest.dart @@ -210,6 +210,7 @@ class ArchiveGlobals { required this.showUltOrbs, required this.showRegionNames, required this.customColorValues, + this.hasCustomColorValues = true, required this.favoriteAgents, }); @@ -219,6 +220,7 @@ class ArchiveGlobals { final bool? showUltOrbs; final bool? showRegionNames; final List customColorValues; + final bool hasCustomColorValues; final List favoriteAgents; Map toJson() { @@ -242,6 +244,8 @@ class ArchiveGlobals { final appPreferencesMap = appPreferences is Map ? Map.from(appPreferences) : null; + final hasCustomColorValues = + appPreferencesMap?.containsKey('customColorValues') ?? false; return ArchiveGlobals( themeProfiles: _readRequiredList(json, 'themeProfiles') @@ -268,6 +272,7 @@ class ArchiveGlobals { : _readOptionalList(appPreferencesMap, 'customColorValues') .map(_readIntValue) .toList(growable: false), + hasCustomColorValues: hasCustomColorValues, favoriteAgents: _readRequiredList(json, 'favoriteAgents') .map((entry) => entry.toString()) .toList(growable: false), diff --git a/lib/services/auth_callback_uri.dart b/lib/services/auth_callback_uri.dart new file mode 100644 index 00000000..b2e320b4 --- /dev/null +++ b/lib/services/auth_callback_uri.dart @@ -0,0 +1,144 @@ +/// Where Supabase sends desktop builds back after Discord sign-in. +final Uri nativeAuthRedirectUri = Uri( + scheme: 'icarus', + host: 'auth', + path: '/callback', +); + +/// Where Supabase sends a browser back after Discord sign-in: the root of the +/// page's own origin. +/// +/// The root, not the page's current path, so each origin needs exactly one +/// entry in the Supabase redirect allowlist. +Uri webAuthRedirectUri(Uri pageUri) => Uri.parse('${pageUri.origin}/'); + +/// What a URI arriving on this build's auth redirect carries. +enum AuthCallback { + /// Not a sign-in callback. Leave it to other handlers. + none, + + /// A PKCE `code` or an error from Supabase: safe to hand to + /// `getSessionFromUrl`. A code only exchanges with the verifier this + /// device saved when it started sign-in, so a forged code just fails. + signInResult, + + /// Session tokens in the URL. We only use the PKCE flow, so Supabase never + /// sends these; anyone can craft such a link, and GoTrue would sign the + /// user into the account it names. Never exchange it, only scrub it. + injectedTokens, +} + +/// Classifies [uri] against [redirectUri], the redirect this build asked +/// Supabase for. +AuthCallback classifyAuthCallbackUri(Uri uri, {required Uri redirectUri}) { + final landsOnRedirect = + uri.scheme.toLowerCase() == redirectUri.scheme.toLowerCase() && + uri.host.toLowerCase() == redirectUri.host.toLowerCase() && + uri.port == redirectUri.port && + _rootedPath(uri) == _rootedPath(redirectUri); + if (!landsOnRedirect) { + return AuthCallback.none; + } + + // Parameter names compared in lowercase, so `ACCESS_TOKEN=` is rejected too. + final Set query; + final Set fragment; + try { + query = _lowercaseKeys(uri.queryParameters.keys); + fragment = _lowercaseKeys(Uri.splitQueryString(uri.fragment).keys); + } on FormatException { + // Undecodable escapes: nothing Supabase sends. Fail closed. + return AuthCallback.injectedTokens; + } + bool has(String key) => query.contains(key) || fragment.contains(key); + + if (_sessionTokenParameters.any(has)) { + return AuthCallback.injectedTokens; + } + if (query.contains('code') || + has('error') || + has('error_code') || + has('error_description')) { + return AuthCallback.signInResult; + } + return AuthCallback.none; +} + +const _sessionTokenParameters = { + 'access_token', + 'refresh_token', + 'provider_token', + 'provider_refresh_token', +}; + +Set _lowercaseKeys(Iterable keys) => + {for (final key in keys) key.toLowerCase()}; + +bool _isAuthCallbackParameter(String key) => + _authCallbackParameters.contains(key.toLowerCase()); + +String _rootedPath(Uri uri) { + final path = uri.path.toLowerCase(); + return path.isEmpty ? '/' : path; +} + +/// The parameters Supabase adds to the redirect URI. Mirrors the list +/// supabase_flutter strips when it detects sessions itself. +const _authCallbackParameters = { + 'code', + 'access_token', + 'expires_in', + 'expires_at', + 'refresh_token', + 'token_type', + 'provider_token', + 'provider_refresh_token', + 'error', + 'error_code', + 'error_description', + 'type', +}; + +/// [uri] without the sign-in result Supabase added, keeping everything else +/// (including a Flutter `#/route` fragment). Once handled, the code is spent; +/// leaving it in the address bar would re-run the callback on refresh. +Uri withoutAuthCallbackParameters(Uri uri) { + Map> query; + try { + query = Map.of(uri.queryParametersAll) + ..removeWhere((key, _) => _isAuthCallbackParameter(key)); + } on FormatException { + query = const {}; // Undecodable, so drop it rather than keep a payload. + } + + return Uri( + scheme: uri.scheme, + userInfo: uri.userInfo, + host: uri.host, + port: uri.hasPort ? uri.port : null, + path: uri.path, + queryParameters: query.isEmpty ? null : query, + fragment: _withoutAuthFragmentParameters(uri.fragment), + ); +} + +String? _withoutAuthFragmentParameters(String fragment) { + if (fragment.isEmpty) { + return null; + } + + final Map> parameters; + try { + parameters = Uri(query: fragment).queryParametersAll; + } on FormatException { + return null; // Undecodable, so it cannot be a route worth keeping. + } + final kept = { + for (final entry in parameters.entries) + if (!_isAuthCallbackParameter(entry.key)) entry.key: entry.value, + }; + if (kept.length == parameters.length) { + return fragment; + } + return kept.isEmpty ? null : Uri(queryParameters: kept).query; +} diff --git a/lib/services/browser_url.dart b/lib/services/browser_url.dart new file mode 100644 index 00000000..bcdf45e3 --- /dev/null +++ b/lib/services/browser_url.dart @@ -0,0 +1,2 @@ +export 'browser_url_stub.dart' + if (dart.library.js_interop) 'browser_url_web.dart'; diff --git a/lib/services/browser_url_stub.dart b/lib/services/browser_url_stub.dart new file mode 100644 index 00000000..60b90fd5 --- /dev/null +++ b/lib/services/browser_url_stub.dart @@ -0,0 +1,6 @@ +/// Replaces the address bar URL without navigating. Native builds have no +/// address bar, so this does nothing. +void replaceBrowserUrl(Uri url) {} + +/// Loads the page again. Native builds have no page, so this does nothing. +void reloadBrowserPage() {} diff --git a/lib/services/browser_url_web.dart b/lib/services/browser_url_web.dart new file mode 100644 index 00000000..df1917d6 --- /dev/null +++ b/lib/services/browser_url_web.dart @@ -0,0 +1,25 @@ +import 'dart:js_interop'; + +@JS('history') +external _History get _history; + +extension type _History._(JSObject _) implements JSObject { + external JSAny? get state; + external void replaceState(JSAny? data, String unused, String url); +} + +@JS('location') +external _Location get _location; + +extension type _Location._(JSObject _) implements JSObject { + external void reload(); +} + +/// Replaces the address bar URL without navigating or adding a history entry. +/// Keeps the current history state, which Flutter's router owns. +void replaceBrowserUrl(Uri url) { + _history.replaceState(_history.state, '', url.toString()); +} + +/// Loads the page again, and with it the app build being served now. +void reloadBrowserPage() => _location.reload(); diff --git a/lib/services/cloud_library_action.dart b/lib/services/cloud_library_action.dart new file mode 100644 index 00000000..5a3bc9c3 --- /dev/null +++ b/lib/services/cloud_library_action.dart @@ -0,0 +1,108 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; + +enum CloudLibraryActionStatus { + succeeded, + cancelled, + authenticationRequired, + failed, +} + +class CloudLibraryActionResult { + const CloudLibraryActionResult._(this.status, {this.userMessage}); + + static const succeeded = CloudLibraryActionResult._( + CloudLibraryActionStatus.succeeded, + ); + static const cancelled = CloudLibraryActionResult._( + CloudLibraryActionStatus.cancelled, + ); + static const authenticationRequired = CloudLibraryActionResult._( + CloudLibraryActionStatus.authenticationRequired, + userMessage: 'Reconnect to Icarus Cloud, then try again.', + ); + + factory CloudLibraryActionResult.failed(String userMessage) => + CloudLibraryActionResult._( + CloudLibraryActionStatus.failed, + userMessage: userMessage, + ); + + final CloudLibraryActionStatus status; + final String? userMessage; + + bool get didSucceed => status == CloudLibraryActionStatus.succeeded; + bool get wasCancelled => status == CloudLibraryActionStatus.cancelled; +} + +final cloudLibraryActionReporterProvider = Provider( + (_) => const CloudLibraryActionReporter(), +); + +class CloudLibraryActionReporter { + const CloudLibraryActionReporter({ + this.showMessage = _showDefaultMessage, + this.reportTechnicalFailure = _reportDefaultTechnicalFailure, + }); + + final void Function(String message) showMessage; + final void Function({ + required String source, + required Object error, + required StackTrace stackTrace, + }) reportTechnicalFailure; + + Future run({ + required Future Function() action, + required String source, + required String failureMessage, + required Future Function(Object error, StackTrace stackTrace) + reportAuthenticationFailure, + bool showFailureMessage = false, + }) async { + try { + final completed = await action(); + return completed + ? CloudLibraryActionResult.succeeded + : CloudLibraryActionResult.cancelled; + } catch (error, stackTrace) { + if (isConvexUnauthenticatedError(error)) { + await reportAuthenticationFailure(error, stackTrace); + return CloudLibraryActionResult.authenticationRequired; + } + + reportTechnicalFailure( + source: source, + error: error, + stackTrace: stackTrace, + ); + if (showFailureMessage) { + showMessage(failureMessage); + } + return CloudLibraryActionResult.failed(failureMessage); + } + } + + static void _showDefaultMessage(String message) { + Settings.showToast( + message: message, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + + static void _reportDefaultTechnicalFailure({ + required String source, + required Object error, + required StackTrace stackTrace, + }) { + AppErrorReporter.reportError( + 'Cloud library action failed.', + source: source, + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + } +} diff --git a/lib/services/cloud_sign_out_coordinator.dart b/lib/services/cloud_sign_out_coordinator.dart new file mode 100644 index 00000000..03796735 --- /dev/null +++ b/lib/services/cloud_sign_out_coordinator.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef CloudSignOutPreparation = Future Function(); +typedef CloudEditorClose = Future Function(); +typedef RawSignOut = Future Function(); + +final cloudSignOutPreparationProvider = Provider( + (ref) => () async { + final strategy = ref.read(strategyProvider); + if (strategy.source != StrategySource.cloud || + strategy.strategyId == null) { + return; + } + ref.read(textDraftProvider.notifier).commitAllDrafts(); + await ref + .read(strategyProvider.notifier) + .forceSaveNow(strategy.strategyId!); + // Reconciliation promotes staged media only after the durable strategy op + // proves its exact reference. Network processing continues independently. + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + }, +); + +final cloudEditorCloseProvider = Provider( + (ref) => () async { + if (ref.read(strategyProvider).source == StrategySource.cloud) { + await ref.read(strategyProvider.notifier).clearCurrentStrategy(); + } + }, +); + +final rawSignOutProvider = Provider( + (ref) => () async { + await ref.read(authProvider.notifier).signOut(); + return !ref.read(authProvider).isAuthenticated; + }, +); + +final cloudSignOutRequestProvider = Provider( + (ref) { + var requestInProgress = false; + return (context) async { + if (requestInProgress) return false; + requestInProgress = true; + try { + return await _requestCloudSafeSignOut(context, ref); + } finally { + requestInProgress = false; + } + }; + }, +); + +Future _requestCloudSafeSignOut( + BuildContext context, + Ref ref, +) async { + final accountId = ref.read(authProvider).user?.id; + if (accountId == null) return false; + + try { + await ref.read(cloudSignOutPreparationProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategy = ref.read(strategyProvider); + final saveState = ref.read(strategySaveStateProvider); + final opQueue = ref.read(strategyOpQueueProvider); + final mediaQueue = ref.read(cloudMediaUploadQueueProvider); + final currentStrategyId = + strategy.source == StrategySource.cloud ? strategy.strategyId : null; + final stagedMedia = mediaQueue.jobs + .where((job) => + job.accountId == accountId && + currentStrategyId == job.strategyPublicId && + !job.referenceDurable) + .toList(growable: false); + final hasUnstagedActiveWork = ref.read(textDraftProvider).isNotEmpty || + stagedMedia.isNotEmpty || + saveState.isSaving || + (saveState.isDirty && + opQueue.pending.isEmpty && + mediaQueue.jobsForStrategy(currentStrategyId).isEmpty); + if (!opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + hasUnstagedActiveWork) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) + if (job.accountId == accountId) job.strategyPublicId, + }; + final workCount = opQueue.accountOutbox.workCount + + mediaQueue.jobs.where((job) => job.accountId == accountId).length; + if (!context.mounted) return false; + final confirmed = await _showSignOutConfirmation( + context, + workCount: workCount, + strategyIds: strategyIds, + currentStrategyId: currentStrategyId, + currentStrategyName: strategy.strategyName, + strategyNames: ref.read(cloudStrategyNamesProvider), + ); + if (!confirmed) return false; + + try { + await ref.read(cloudEditorCloseProvider)(); + } catch (_) { + if (context.mounted) await _showPersistenceBlocked(context); + return false; + } + final signedOut = await ref.read(rawSignOutProvider)(); + if (!signedOut) { + if (context.mounted) { + await _showSignOutFailed( + context, + ref.read(authProvider).errorMessage, + ); + } + return false; + } + if (context.mounted) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + return true; +} + +Future _showPersistenceBlocked(BuildContext context) { + return showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not confirm that all pending cloud work is saved on ' + 'this device. Stay signed in and try again.', + ), + ), + actions: [ + ShadButton( + key: const ValueKey('sign-out-persistence-blocked'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); +} + +Future _showSignOutConfirmation( + BuildContext context, { + required int workCount, + required Set strategyIds, + required String? currentStrategyId, + required String? currentStrategyName, + required Map strategyNames, +}) async { + final hasPendingWork = workCount > 0; + final strategyLabels = strategyIds + .map((id) => id == currentStrategyId && + currentStrategyName?.trim().isNotEmpty == true + ? currentStrategyName!.trim() + : (strategyNames[id]?.trim().isNotEmpty == true + ? strategyNames[id]!.trim() + : 'a cloud strategy')) + .toSet() + .join(', '); + final result = await showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => ShadDialog.alert( + title: Text(hasPendingWork ? 'Cloud work is still waiting' : 'Sign out?'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text( + hasPendingWork + ? '$workCount saved ${workCount == 1 ? 'change' : 'changes'} ' + 'across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy is' : 'strategies are'} ' + 'still waiting: $strategyLabels. The work remains on this ' + 'device and resumes only when this same account signs in ' + 'again.' + : 'Cloud strategies stay online. Pending work on this device ' + 'will remain tied to this account.', + ), + ), + actions: [ + ShadButton.secondary( + key: const ValueKey('sign-out-cancel'), + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('sign-out-confirm'), + onPressed: () => Navigator.of(context).pop(true), + child: Text(hasPendingWork ? 'Sign Out Anyway' : 'Sign Out'), + ), + ], + ), + ); + return result ?? false; +} + +Future _showSignOutFailed(BuildContext context, String? message) { + return showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text('Sign out failed'), + description: Padding( + padding: const EdgeInsets.all(8), + child: Text(message ?? 'Icarus could not sign out. Please try again.'), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); +} diff --git a/lib/services/cloud_strategy_export.dart b/lib/services/cloud_strategy_export.dart new file mode 100644 index 00000000..24dcd745 --- /dev/null +++ b/lib/services/cloud_strategy_export.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; + +Future runCloudStrategyExport( + WidgetRef ref, + String strategyId, +) { + const source = 'strategy:export'; + return ref.read(cloudLibraryActionReporterProvider).run( + action: () => ref.read(cloudStrategyExporterProvider)(strategyId), + source: source, + failureMessage: "Couldn't export this cloud strategy. Try again.", + showFailureMessage: true, + reportAuthenticationFailure: (error, stackTrace) => + ref.read(authProvider.notifier).reportConvexUnauthenticated( + source: source, + error: error, + stackTrace: stackTrace, + ), + ); +} diff --git a/lib/services/deep_link_registrar.dart b/lib/services/deep_link_registrar.dart new file mode 100644 index 00000000..c43aa042 --- /dev/null +++ b/lib/services/deep_link_registrar.dart @@ -0,0 +1,2 @@ +export 'deep_link_registrar_stub.dart' + if (dart.library.io) 'deep_link_registrar_native.dart'; diff --git a/lib/services/deep_link_registrar_native.dart b/lib/services/deep_link_registrar_native.dart new file mode 100644 index 00000000..33687c41 --- /dev/null +++ b/lib/services/deep_link_registrar_native.dart @@ -0,0 +1,64 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:win32_registry/win32_registry.dart'; + +Future registerDeepLinkProtocol(String scheme) async { + if (!Platform.isWindows) { + return; + } + + final appPath = Platform.resolvedExecutable; + final expectedCommand = '"$appPath" "%1"'; + + final protocolRegKey = 'Software\\Classes\\$scheme'; + const protocolRegValue = RegistryValue.string('URL Protocol', ''); + const protocolCmdRegKey = 'shell\\open\\command'; + + final currentCommand = _readCurrentProtocolCommand(scheme); + final looksLikeDevBuild = _looksLikeDevBuildPath(appPath); + final canOverwriteDevRegistration = !looksLikeDevBuild || + const bool.fromEnvironment('ICARUS_FORCE_PROTOCOL_REGISTER'); + + if (!canOverwriteDevRegistration && + currentCommand != null && + currentCommand.isNotEmpty && + currentCommand != expectedCommand) { + log( + 'Deep link registration skipped for dev executable. ' + 'existing="$currentCommand" ' + 'resolvedExecutable="$appPath"', + name: 'deep_link_registrar', + ); + return; + } + + final regKey = Registry.currentUser.createKey(protocolRegKey); + regKey.createValue(protocolRegValue); + regKey + .createKey(protocolCmdRegKey) + .createValue(RegistryValue.string('', expectedCommand)); + + log( + 'Deep link registration updated. ' + 'scheme="$scheme" command="$expectedCommand"', + name: 'deep_link_registrar', + ); +} + +String? _readCurrentProtocolCommand(String scheme) { + final path = 'Software\\Classes\\$scheme\\shell\\open\\command'; + try { + final key = Registry.openPath(RegistryHive.currentUser, path: path); + final value = key.getStringValue(''); + key.close(); + return value; + } catch (_) { + return null; + } +} + +bool _looksLikeDevBuildPath(String appPath) { + final normalized = appPath.toLowerCase().replaceAll('/', '\\'); + return normalized.contains('\\build\\windows\\x64\\runner\\debug\\'); +} diff --git a/lib/services/deep_link_registrar_stub.dart b/lib/services/deep_link_registrar_stub.dart new file mode 100644 index 00000000..98629f0f --- /dev/null +++ b/lib/services/deep_link_registrar_stub.dart @@ -0,0 +1 @@ +Future registerDeepLinkProtocol(String scheme) async {} diff --git a/lib/services/guarded_sign_out.dart b/lib/services/guarded_sign_out.dart new file mode 100644 index 00000000..3d586495 --- /dev/null +++ b/lib/services/guarded_sign_out.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +typedef GuardedSignOutRequest = Future Function(BuildContext context); + +/// The app shell replaces this with the cloud-aware implementation. +/// +/// Keeping the default fail-closed lets auth UI depend on one contract without +/// introducing a provider import cycle through the editor state. +final guardedSignOutRequestProvider = Provider( + (ref) => (context) async { + await showShadDialog( + context: context, + builder: (context) => ShadDialog.alert( + title: const Text("Can't sign out yet"), + description: const Padding( + padding: EdgeInsets.all(8), + child: Text( + 'Icarus could not verify pending cloud work. Stay signed in and ' + 'try again.', + ), + ), + actions: [ + ShadButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Stay Signed In'), + ), + ], + ), + ); + return false; + }, +); diff --git a/lib/services/local_image_file.dart b/lib/services/local_image_file.dart new file mode 100644 index 00000000..593547f1 --- /dev/null +++ b/lib/services/local_image_file.dart @@ -0,0 +1,4 @@ +// Strategy image files on this device. Web keeps no image files, so there +// every lookup misses and images come from their cloud URL instead. +export 'local_image_file_stub.dart' + if (dart.library.io) 'local_image_file_native.dart'; diff --git a/lib/services/local_image_file_native.dart b/lib/services/local_image_file_native.dart new file mode 100644 index 00000000..d178f39a --- /dev/null +++ b/lib/services/local_image_file_native.dart @@ -0,0 +1,29 @@ +import 'dart:io'; + +import 'package:flutter/painting.dart'; +import 'package:path/path.dart' as path; + +/// Whether this platform keeps strategy images as files on the device. +const bool deviceHasImageFiles = true; + +/// The path of an image's file under [storageDirectory], or null when the +/// file is not on this device. +String? findLocalImageFile({ + required String? storageDirectory, + required String imageId, + required String? fileExtension, +}) { + if (storageDirectory == null || fileExtension == null) return null; + final filePath = + path.join(storageDirectory, 'images', '$imageId$fileExtension'); + return File(filePath).existsSync() ? filePath : null; +} + +ImageProvider localImageProvider(String filePath) => FileImage(File(filePath)); + +/// The size of the image file at [filePath], for uploading it. +Future localImageFileLength(String filePath) => File(filePath).length(); + +/// The bytes of the image file at [filePath], for uploading it. +Stream> readLocalImageFile(String filePath) => + File(filePath).openRead(); diff --git a/lib/services/local_image_file_stub.dart b/lib/services/local_image_file_stub.dart new file mode 100644 index 00000000..903d53e8 --- /dev/null +++ b/lib/services/local_image_file_stub.dart @@ -0,0 +1,22 @@ +import 'package:flutter/painting.dart'; + +/// Whether this platform keeps strategy images as files on the device. +const bool deviceHasImageFiles = false; + +/// The path of an image's file under [storageDirectory], or null when the +/// file is not on this device. There are no image files here. +String? findLocalImageFile({ + required String? storageDirectory, + required String imageId, + required String? fileExtension, +}) => + null; + +ImageProvider localImageProvider(String filePath) => + throw UnsupportedError('No image files on this platform: $filePath'); + +Future localImageFileLength(String filePath) => + throw UnsupportedError('No image files on this platform: $filePath'); + +Stream> readLocalImageFile(String filePath) => + throw UnsupportedError('No image files on this platform: $filePath'); diff --git a/lib/services/open_cloud_strategy_store.dart b/lib/services/open_cloud_strategy_store.dart new file mode 100644 index 00000000..b878423e --- /dev/null +++ b/lib/services/open_cloud_strategy_store.dart @@ -0,0 +1,72 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/open_cloud_strategy_store_stub.dart' + if (dart.library.js_interop) 'package:icarus/services/open_cloud_strategy_store_web.dart' + as platform; +import 'package:icarus/strategy/strategy_page_models.dart'; + +/// The cloud strategy open in the editor, so a page reload can reopen it. +/// +/// On web a reload restarts the app on the editor's route, which carries no +/// strategy; the web store keeps the id in the tab's sessionStorage. Native +/// builds never reload, so memory is enough there. +abstract interface class OpenCloudStrategyStore { + String? read(); + void write(String strategyPublicId); + void clear(); +} + +class MemoryOpenCloudStrategyStore implements OpenCloudStrategyStore { + String? _strategyPublicId; + + @override + String? read() => _strategyPublicId; + + @override + void write(String strategyPublicId) => _strategyPublicId = strategyPublicId; + + @override + void clear() => _strategyPublicId = null; +} + +/// sessionStorage on web, memory on native. +OpenCloudStrategyStore createOpenCloudStrategyStore() => + platform.createOpenCloudStrategyStore(); + +final openCloudStrategyStoreProvider = Provider( + (ref) => createOpenCloudStrategyStore(), +); + +/// While watched (by the editor), keeps [openCloudStrategyStoreProvider] +/// naming the cloud strategy open in the editor. It follows +/// [strategyProvider] rather than the route, because the open strategy +/// changes in place (quick switcher, back/forward, a new strategy). When the +/// editor goes away the store is cleared. A strategy open through a share +/// link is not recorded: its page keeps the /share/ URL, and a reload +/// reopens it through the link. +final openCloudStrategyRecorderProvider = Provider.autoDispose((ref) { + final store = ref.watch(openCloudStrategyStoreProvider); + void record() { + final open = ref.read(strategyProvider); + final id = open.strategyId; + final throughLink = ref.read(shareLinkViewProvider)?.strategyPublicId == id; + if (open.source == StrategySource.cloud && id != null && !throughLink) { + store.write(id); + } else { + store.clear(); + } + } + + ref.listen( + strategyProvider.select( + (strategy) => (id: strategy.strategyId, source: strategy.source), + ), + (_, __) => record(), + fireImmediately: true, + ); + // Redeeming the link of the strategy already open changes neither its id + // nor its source, but it is now the reader's own and reloads like one. + ref.listen(shareLinkViewProvider, (_, __) => record()); + ref.onDispose(store.clear); +}); diff --git a/lib/services/open_cloud_strategy_store_stub.dart b/lib/services/open_cloud_strategy_store_stub.dart new file mode 100644 index 00000000..d9979881 --- /dev/null +++ b/lib/services/open_cloud_strategy_store_stub.dart @@ -0,0 +1,4 @@ +import 'package:icarus/services/open_cloud_strategy_store.dart'; + +OpenCloudStrategyStore createOpenCloudStrategyStore() => + MemoryOpenCloudStrategyStore(); diff --git a/lib/services/open_cloud_strategy_store_web.dart b/lib/services/open_cloud_strategy_store_web.dart new file mode 100644 index 00000000..193df9a2 --- /dev/null +++ b/lib/services/open_cloud_strategy_store_web.dart @@ -0,0 +1,50 @@ +import 'dart:js_interop'; + +import 'package:icarus/services/open_cloud_strategy_store.dart'; + +OpenCloudStrategyStore createOpenCloudStrategyStore() => + _SessionOpenCloudStrategyStore(); + +@JS('sessionStorage') +external _Storage get _sessionStorage; + +extension type _Storage._(JSObject _) implements JSObject { + external String? getItem(String key); + external void setItem(String key, String value); + external void removeItem(String key); +} + +/// Keeps the id in this tab's sessionStorage, which survives a reload and +/// dies with the tab. If the browser refuses storage (some privacy modes +/// throw on access), the id lives in memory and a reload returns to the +/// library. +class _SessionOpenCloudStrategyStore implements OpenCloudStrategyStore { + static const _key = 'icarus.openCloudStrategy'; + final _fallback = MemoryOpenCloudStrategyStore(); + + @override + String? read() { + try { + return _sessionStorage.getItem(_key); + } catch (_) { + return _fallback.read(); + } + } + + @override + void write(String strategyPublicId) { + try { + _sessionStorage.setItem(_key, strategyPublicId); + } catch (_) { + _fallback.write(strategyPublicId); + } + } + + @override + void clear() { + _fallback.clear(); + try { + _sessionStorage.removeItem(_key); + } catch (_) {} + } +} diff --git a/lib/services/unsaved_strategy_guard.dart b/lib/services/unsaved_strategy_guard.dart index 0b93e7a3..0b2ae9e4 100644 --- a/lib/services/unsaved_strategy_guard.dart +++ b/lib/services/unsaved_strategy_guard.dart @@ -1,7 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; enum UnsavedStrategyDecision { @@ -10,6 +18,13 @@ enum UnsavedStrategyDecision { cancel, } +enum CloudExitDecision { + stay, + leaveAnyway, + retrySync, + retryAuth, +} + Future showUnsavedStrategyDialog( BuildContext context, ) async { @@ -48,6 +63,312 @@ Future showUnsavedStrategyDialog( return result ?? UnsavedStrategyDecision.cancel; } +Future _showCloudSyncBlockedDialog( + BuildContext context, { + required String message, + required bool allowLeaveAnyway, + required bool showRetryAuth, +}) async { + final result = await showShadDialog( + context: context, + builder: (context) { + return ShadDialog.alert( + title: const Text('Cloud sync pending'), + description: Text(message), + // One row that wraps when the labels need more room: the quiet way + // out first, the command last. + actions: [ + Expanded( + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + ShadButton.ghost( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.stay); + }, + child: const Text('Stay here'), + ), + if (showRetryAuth) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retryAuth); + }, + child: const Text('Retry sign-in'), + ), + if (allowLeaveAnyway) + ShadButton.secondary( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.leaveAnyway); + }, + child: const Text('Leave anyway'), + ), + ShadButton( + onPressed: () { + Navigator.of(context).pop(CloudExitDecision.retrySync); + }, + child: const Text('Retry sync'), + ), + ], + ), + ), + ], + ); + }, + ); + + return result ?? CloudExitDecision.stay; +} + +Future _waitForCloudSync( + WidgetRef ref, { + Duration timeout = const Duration(seconds: 8), + Duration pollInterval = const Duration(milliseconds: 120), +}) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final strategyId = ref.read(strategyProvider).strategyId; + final saveState = ref.read(strategySaveStateProvider); + final queueState = ref.read(strategyOpQueueProvider); + final mediaQueueState = ref.read(cloudMediaUploadQueueProvider); + final mediaJobs = mediaQueueState.jobsForStrategy(strategyId); + if (!saveState.hasPendingCloudSync && + !saveState.hasPendingMediaSync && + mediaJobs.isEmpty && + queueState.pending.isEmpty && + !queueState.isFlushing && + !mediaQueueState.isProcessing && + saveState.cloudSyncError == null && + saveState.mediaSyncErrorCount == 0) { + return true; + } + await Future.delayed(pollInterval); + } + return false; +} + +Future _guardCloudStrategyExit({ + required BuildContext context, + required WidgetRef ref, + required Future Function() onContinue, +}) async { + final openingStrategy = ref.read(strategyProvider); + final openingSaveState = ref.read(strategySaveStateProvider); + if ((ref.read(textDraftProvider).isNotEmpty || openingSaveState.isDirty) && + openingStrategy.strategyId != null) { + ref.read(textDraftProvider.notifier).commitAllDrafts(); + try { + await ref + .read(strategyProvider.notifier) + .forceSaveNow(openingStrategy.strategyId!); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to sync the active text edit before leaving.', + error: error, + stackTrace: stackTrace, + source: 'cloud_media.exit_guard', + ); + return false; + } + } + + // Set when the user chose Leave anyway. It is honoured only if the work + // is still all in the durable outboxes when the state is read again: + // something may have changed while the dialog was open. + var leaveIfDurable = false; + while (true) { + final strategyState = ref.read(strategyProvider); + final saveState = ref.read(strategySaveStateProvider); + final queueState = ref.read(strategyOpQueueProvider); + final authState = ref.read(authProvider); + final mediaQueueState = ref.read(cloudMediaUploadQueueProvider); + final mediaJobs = mediaQueueState.jobsForStrategy(strategyState.strategyId); + final hasPendingMediaJobs = mediaJobs.isNotEmpty; + final outboxError = !queueState.outboxIsReliable + ? 'Icarus could not confirm that cloud edits are saved on this device.' + : (!mediaQueueState.outboxIsReliable + ? (mediaQueueState.durabilityError ?? + 'Icarus could not confirm that media work is saved on this device.') + : null); + + final hasPendingSync = saveState.hasPendingCloudSync || + saveState.hasPendingMediaSync || + hasPendingMediaJobs || + queueState.pending.isNotEmpty; + final cloudError = + saveState.cloudSyncError ?? queueState.lastError ?? outboxError; + final hasDurablePendingWork = + queueState.pending.isNotEmpty || hasPendingMediaJobs; + final hasUncommittedMediaReferences = mediaJobs.any( + (job) => !job.referenceDurable, + ); + final hasUnstagedWork = ref.read(textDraftProvider).isNotEmpty || + hasUncommittedMediaReferences || + (saveState.isDirty && !hasDurablePendingWork); + final hasUnreadableSavedWork = queueState.loadIssues.isNotEmpty || + mediaQueueState.loadIssues.isNotEmpty; + final canLeaveWithDurableWork = !hasUnstagedWork && + queueState.durableLoaded && + mediaQueueState.durableLoaded && + !queueState.hasDurabilityFailure && + mediaQueueState.durabilityError == null && + ((hasDurablePendingWork && + queueState.outboxIsReliable && + mediaQueueState.outboxIsReliable) || + hasUnreadableSavedWork); + if (leaveIfDurable) { + leaveIfDurable = false; + if (canLeaveWithDurableWork) { + if (!context.mounted) return false; + await onContinue(); + return true; + } + } + final isConnected = ref.read(convexConnectionSnapshotProvider); + AppErrorReporter.reportInfo( + 'Cloud exit guard check: strategy=${strategyState.strategyId} ' + 'dirty=${saveState.isDirty} saving=${saveState.isSaving} ' + 'pendingCloud=${saveState.hasPendingCloudSync} ' + 'pendingMedia=${saveState.hasPendingMediaSync} ' + 'mediaErrors=${saveState.mediaSyncErrorCount} ' + 'opPending=${queueState.pending.length} ' + 'opFlushing=${queueState.isFlushing} ' + 'mediaJobs=${mediaQueueState.jobs.length} ' + 'mediaProcessing=${mediaQueueState.isProcessing} ' + 'auth=${authState.isAuthenticated} ' + 'userReady=${authState.isConvexUserReady} ' + 'authIncident=${authState.hasActiveAuthIncident} ' + 'connected=$isConnected ' + 'durablePending=$hasDurablePendingWork ' + 'uncommittedMediaReferences=$hasUncommittedMediaReferences ' + 'canLeaveWithDurableWork=$canLeaveWithDurableWork ' + 'cloudError=${cloudError ?? 'none'}', + source: 'cloud_media.exit_guard', + ); + // Nothing pending and nothing new since the save on entry: a draft or + // edit made during the wait would still be unstaged. + if (!hasPendingSync && cloudError == null && !hasUnstagedWork) { + if (!context.mounted) { + return false; + } + AppErrorReporter.reportInfo( + 'Cloud exit guard allowing leave: strategy=${strategyState.strategyId}', + source: 'cloud_media.exit_guard', + ); + await onContinue(); + return true; + } + + if ((queueState.isFlushing || mediaQueueState.isProcessing) && + cloudError == null) { + AppErrorReporter.reportInfo( + 'Cloud exit guard waiting for active op flush: ' + 'strategy=${strategyState.strategyId}', + source: 'cloud_media.exit_guard', + ); + final synced = await _waitForCloudSync(ref); + if (synced) { + AppErrorReporter.reportInfo( + 'Cloud exit guard wait completed; rechecking sync state.', + source: 'cloud_media.exit_guard', + ); + continue; + } + AppErrorReporter.reportInfo( + 'Cloud exit guard wait timed out; showing blocked dialog.', + source: 'cloud_media.exit_guard', + ); + } + + if (!context.mounted) { + return false; + } + + final decision = await _showCloudSyncBlockedDialog( + context, + message: _cloudSyncBlockedMessage( + isConnected: isConnected, + cloudError: cloudError, + mediaErrorCount: saveState.mediaSyncErrorCount, + canLeaveWithDurableWork: canLeaveWithDurableWork, + hasUnreadableSavedWork: hasUnreadableSavedWork, + hasRefusedWork: queueState.attentionByEntityKey.isNotEmpty, + ), + allowLeaveAnyway: canLeaveWithDurableWork, + showRetryAuth: authState.hasActiveAuthIncident, + ); + + switch (decision) { + case CloudExitDecision.stay: + AppErrorReporter.reportInfo( + 'Cloud exit guard user chose stay.', + source: 'cloud_media.exit_guard', + ); + return false; + case CloudExitDecision.retryAuth: + AppErrorReporter.reportInfo( + 'Cloud exit guard retrying auth.', + source: 'cloud_media.exit_guard', + ); + await ref + .read(authProvider.notifier) + .reinitializeConvexAuth(source: 'cloud_exit_guard'); + break; + case CloudExitDecision.leaveAnyway: + AppErrorReporter.reportInfo( + 'Cloud exit guard leaving with work pending in durable outboxes.', + source: 'cloud_media.exit_guard', + ); + leaveIfDurable = true; + break; + case CloudExitDecision.retrySync: + AppErrorReporter.reportInfo( + 'Cloud exit guard retrying sync.', + source: 'cloud_media.exit_guard', + ); + final strategyId = strategyState.strategyId; + if (strategyId == null) { + return false; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + break; + } + } +} + +String _cloudSyncBlockedMessage({ + required bool isConnected, + required String? cloudError, + required int mediaErrorCount, + required bool canLeaveWithDurableWork, + required bool hasUnreadableSavedWork, + required bool hasRefusedWork, +}) { + final base = !isConnected + ? 'Icarus is offline, so these changes have not reached the cloud.' + : (cloudError != null + ? friendlyCloudSyncError(cloudError) + : (mediaErrorCount > 0 + ? 'Some images have not reached the cloud.' + : 'Icarus is still sending cloud edits and images.')); + if (canLeaveWithDurableWork) { + if (hasUnreadableSavedWork) { + return '$base Leaving will not delete the saved device records. You ' + 'can return to this strategy and retry.'; + } + // Refused work waits for the user's choice; nothing retries it. + if (hasRefusedWork) { + return '$base You can leave; when you come back, choose what to keep ' + 'from the sync button.'; + } + return '$base The pending work is saved on this device. You can leave ' + 'and Icarus will retry it later.'; + } + return '$base Stay here and retry because Icarus could not confirm that ' + 'all pending work is saved on this device.'; +} + Future guardUnsavedStrategyExit({ required BuildContext context, required WidgetRef ref, @@ -55,7 +376,18 @@ Future guardUnsavedStrategyExit({ required String source, }) async { final strategyState = ref.read(strategyProvider); - if (strategyState.stratName == null || strategyState.isSaved) { + final saveState = ref.read(strategySaveStateProvider); + if (strategyState.source == StrategySource.cloud) { + return _guardCloudStrategyExit( + context: context, + ref: ref, + onContinue: onContinue, + ); + } + + final hasTextDrafts = ref.read(textDraftProvider).isNotEmpty; + if (strategyState.strategyName == null || + (!saveState.isDirty && !hasTextDrafts)) { await onContinue(); return true; } @@ -86,9 +418,11 @@ Future guardUnsavedStrategyExit({ switch (decision) { case UnsavedStrategyDecision.save: try { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(strategyState.id); + final strategyId = strategyState.strategyId; + if (strategyId == null) { + return false; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Failed to save strategy before leaving.', diff --git a/lib/services/video_export/browser_video_sink.dart b/lib/services/video_export/browser_video_sink.dart new file mode 100644 index 00000000..0b45f1dc --- /dev/null +++ b/lib/services/video_export/browser_video_sink.dart @@ -0,0 +1,2 @@ +export 'browser_video_sink_stub.dart' + if (dart.library.js_interop) 'browser_video_sink_web.dart'; diff --git a/lib/services/video_export/browser_video_sink_stub.dart b/lib/services/video_export/browser_video_sink_stub.dart new file mode 100644 index 00000000..9b479cdb --- /dev/null +++ b/lib/services/video_export/browser_video_sink_stub.dart @@ -0,0 +1,14 @@ +import 'package:icarus/services/video_export/video_export_quality.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; + +/// Whether this browser can encode a [totalSeconds] video at [quality]. +/// Never, off the web. +Future browserCanEncodeVideo( + VideoExportQuality quality, { + required double totalSeconds, +}) async => + false; + +/// Encodes in the browser. Off the web there is no browser to encode in. +VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) => + throw UnsupportedError('Browser video encoding exists only on the web.'); diff --git a/lib/services/video_export/browser_video_sink_web.dart b/lib/services/video_export/browser_video_sink_web.dart new file mode 100644 index 00000000..64832195 --- /dev/null +++ b/lib/services/video_export/browser_video_sink_web.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/services/video_export/video_export_quality.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; +import 'package:icarus/services/video_export/web_codecs_video_encoder.dart'; + +/// Whether this browser can encode a [totalSeconds] video at [quality], +/// checked with the exact size and bitrate the export will use, before +/// anything renders. +Future browserCanEncodeVideo( + VideoExportQuality quality, { + required double totalSeconds, +}) { + final output = _outputFor(quality, totalSeconds); + return WebCodecsMp4Encoder.isSupported( + width: output.width, + height: output.height, + fps: quality.fps, + bitrate: output.bitrate, + ); +} + +/// The size and bitrate [quality] encodes a [totalSeconds] video at: the +/// desktop presets' (Potato drops to 720p at its bitrate floor; Max has no +/// fixed bitrate). +({int width, int height, int? bitrate}) _outputFor( + VideoExportQuality quality, + double totalSeconds, +) { + const frame = CoordinateSystem.screenShotSize; + final height = quality.outputHeightForDuration(totalSeconds); + return ( + width: (frame.width * height / frame.height).round(), + height: height, + bitrate: quality.sizePolicy?.initialVideoBitrate(totalSeconds), + ); +} + +/// Encodes in the browser with WebCodecs; [VideoFrameSink.finish] returns +/// the finished .mp4 for the caller to download. +VideoFrameSink createBrowserVideoSink(VideoExportQuality quality) => + _WebCodecsVideoSink(quality); + +/// Sizes and bitrates follow the desktop presets: Potato and Social hold the +/// bitrate their size target allows for the video's length (and Potato +/// drops to 720p at the bitrate floor); Max encodes at a high variable +/// bitrate. The browser encodes as frames arrive, so there is no separate +/// encode pass and no second attempt at a smaller size. +class _WebCodecsVideoSink implements VideoFrameSink { + _WebCodecsVideoSink(this.quality); + + final VideoExportQuality quality; + final WebCodecsMp4Encoder _encoder = WebCodecsMp4Encoder(); + + @override + Future start({ + required int width, + required int height, + required int totalFrames, + required double totalSeconds, + }) { + final output = _outputFor(quality, totalSeconds); + return _encoder.start( + inputWidth: width, + inputHeight: height, + outputWidth: output.width, + outputHeight: output.height, + fps: quality.fps, + bitrate: output.bitrate, + ); + } + + @override + Future addFrame(Uint8List rgba, double durationSeconds) => + _encoder.addFrame( + rgba, + duration: Duration(microseconds: (durationSeconds * 1e6).round()), + ); + + @override + Future finish({ + void Function(double fraction)? onProgress, + }) async { + final video = await _encoder.finish(); + onProgress?.call(1); + return video; + } + + @override + void cancel() => _encoder.cancel(); + + @override + Future close() async => _encoder.cancel(); +} diff --git a/lib/services/video_export/ffmpeg_video_encoder.dart b/lib/services/video_export/ffmpeg_video_encoder.dart index 3816f82c..746a943b 100644 --- a/lib/services/video_export/ffmpeg_video_encoder.dart +++ b/lib/services/video_export/ffmpeg_video_encoder.dart @@ -3,18 +3,11 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:icarus/services/video_export/video_export_errors.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:path/path.dart' as p; -class VideoExportCancelled implements Exception {} - -class VideoExportException implements Exception { - VideoExportException(this.message); - final String message; - - @override - String toString() => 'VideoExportException: $message'; -} +export 'package:icarus/services/video_export/video_export_errors.dart'; /// Windows' save dialog does not guarantee that the selected extension is /// appended, even when FilePicker is restricted to MP4 files. diff --git a/lib/services/video_export/ffmpeg_video_sink.dart b/lib/services/video_export/ffmpeg_video_sink.dart new file mode 100644 index 00000000..0da0087d --- /dev/null +++ b/lib/services/video_export/ffmpeg_video_sink.dart @@ -0,0 +1,116 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:icarus/services/video_export/ffmpeg_png_sequence_writer.dart'; +import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; +import 'package:icarus/services/video_export/video_export_quality.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; +import 'package:path/path.dart' as p; + +/// Desktop: frames go to one ffmpeg process as a lossless PNG sequence, +/// listed with their durations in an ffconcat playlist, and a second ffmpeg +/// run encodes the playlist into [outputPath]. +class FfmpegVideoSink implements VideoFrameSink { + FfmpegVideoSink({ + required this.binary, + required this.outputPath, + required this.quality, + }); + + final String binary; + final String outputPath; + final VideoExportQuality quality; + + final FfmpegVideoEncoder _encoder = FfmpegVideoEncoder(); + final List _concatLines = ['ffconcat version 1.0']; + Directory? _tempDir; + FfmpegPngSequenceWriter? _frameWriter; + double _totalSeconds = 0; + int _frameIndex = 0; + String? _lastFrameFile; + + @override + Future start({ + required int width, + required int height, + required int totalFrames, + required double totalSeconds, + }) async { + _totalSeconds = totalSeconds; + final tempDir = + await Directory.systemTemp.createTemp('icarus_video_export_'); + _tempDir = tempDir; + final frameWriter = FfmpegPngSequenceWriter(); + _frameWriter = frameWriter; + await frameWriter.start( + binary: binary, + workingDirectory: tempDir.path, + width: width, + height: height, + fps: quality.fps, + totalFrames: totalFrames, + ); + } + + @override + Future addFrame(Uint8List rgba, double durationSeconds) async { + final fileName = 'frame_${_frameIndex.toString().padLeft(5, '0')}.png'; + await _frameWriter!.writeFrame(rgba); + _frameIndex++; + _concatLines + ..add("file '$fileName'") + ..add('duration ${durationSeconds.toStringAsFixed(6)}'); + _lastFrameFile = fileName; + } + + @override + Future finish( + {void Function(double fraction)? onProgress}) async { + await _frameWriter!.finish(); + _frameWriter = null; + + // The concat demuxer ignores the duration of the final entry unless the + // last file is repeated. + if (_lastFrameFile != null) { + _concatLines.add("file '$_lastFrameFile'"); + } + final tempDir = _tempDir!; + await File(p.join(tempDir.path, 'frames.ffconcat')) + .writeAsString(_concatLines.join('\n')); + + await _encoder.encode( + binary: binary, + workingDirectory: tempDir.path, + concatListFileName: 'frames.ffconcat', + outputPath: outputPath, + totalSeconds: _totalSeconds, + quality: quality, + onProgress: onProgress, + ); + return null; + } + + @override + void cancel() { + // close() awaits the writer's termination; here we only need to + // interrupt the processes. + unawaited(_frameWriter?.cancel()); + _encoder.cancel(); + } + + @override + Future close() async { + // Wait for ffmpeg to exit before deleting the directory it writes into; + // on Windows the delete races a still-exiting process otherwise. + await _frameWriter?.cancel(); + _frameWriter = null; + try { + await _tempDir?.delete(recursive: true); + _tempDir = null; + } on Object { + // Leaving orphaned temp frames behind is preferable to masking the + // original export result. + } + } +} diff --git a/lib/services/video_export/mp4_muxer.dart b/lib/services/video_export/mp4_muxer.dart new file mode 100644 index 00000000..1dae7ae5 --- /dev/null +++ b/lib/services/video_export/mp4_muxer.dart @@ -0,0 +1,357 @@ +import 'dart:typed_data'; + +/// How a track's decoded samples map to colour, as ISO/IEC 23091-2 code +/// points (the H.264 VUI's): written as the sample entry's `colr` box. +/// +/// Players that find no colour description assume limited range. An +/// encoder that writes full-range samples without saying so in its +/// bitstream gets its darks crushed to black unless the container says so. +class Mp4ColorInfo { + const Mp4ColorInfo({ + required this.primaries, + required this.transfer, + required this.matrix, + required this.fullRange, + }); + + final int primaries; + final int transfer; + final int matrix; + final bool fullRange; +} + +/// Builds an MP4 file holding one H.264 video track, entirely in memory. +/// +/// The layout is faststart (ftyp, moov, mdat) so browsers and chat apps can +/// start playing before the whole file has arrived. All samples live in a +/// single chunk, so the file needs exactly one chunk offset. +/// +/// Samples are stored in the order they are added and presented in that same +/// order: there is no composition offset table, so the stream must not +/// contain B-frames (decode order must equal presentation order). +/// +/// Pure Dart and 64-bit values are written as two 32-bit halves, so this runs +/// unchanged on the VM and on the web. +class Mp4H264Muxer { + Mp4H264Muxer({ + required this.width, + required this.height, + this.timescale = 90000, + }) { + if (width <= 0 || width > 0xFFFF || height <= 0 || height > 0xFFFF) { + throw ArgumentError('Video size must be within 1..65535 on each side.'); + } + if (timescale <= 0 || timescale > _maxUint32) { + throw ArgumentError.value(timescale, 'timescale', 'must be positive'); + } + } + + final int width; + final int height; + + /// Ticks per second for every duration passed to [addSample]. + final int timescale; + + final List _samples = []; + final List _durations = []; + final List _keyFrameNumbers = []; + int _payloadBytes = 0; + int _totalDuration = 0; + bool _finished = false; + Mp4ColorInfo? _color; + + int get sampleCount => _samples.length; + + /// Total duration of the added samples, in [timescale] ticks. + int get totalDuration => _totalDuration; + + /// Adds one access unit in AVCC format (length-prefixed NAL units), exactly + /// as WebCodecs emits it with `avc: {format: 'avc'}`. [duration] is in + /// [timescale] ticks. + void addSample( + Uint8List sample, { + required int duration, + required bool isKeyFrame, + }) { + if (_finished) throw StateError('The MP4 has already been finished.'); + if (sample.isEmpty) { + throw ArgumentError('An H.264 sample cannot be empty.'); + } + if (duration <= 0 || duration > _maxUint32) { + throw ArgumentError.value( + duration, + 'duration', + 'must be between 1 and 2^32-1 ticks', + ); + } + if (_samples.isEmpty && !isKeyFrame) { + throw StateError('The first H.264 sample must be a keyframe.'); + } + _samples.add(sample); + _durations.add(duration); + if (isKeyFrame) _keyFrameNumbers.add(_samples.length); + _payloadBytes += sample.length; + _totalDuration += duration; + } + + /// Returns the finished file. [avcDecoderConfig] is the + /// AVCDecoderConfigurationRecord (the `description` WebCodecs reports in the + /// first chunk's `metadata.decoderConfig`); [color] is how its samples map + /// to colour, when known. + Uint8List finish({ + required Uint8List avcDecoderConfig, + Mp4ColorInfo? color, + }) { + if (_finished) throw StateError('The MP4 has already been finished.'); + if (_samples.isEmpty) { + throw StateError('Cannot build an MP4 without any video samples.'); + } + if (avcDecoderConfig.isEmpty || avcDecoderConfig[0] != 1) { + throw ArgumentError( + 'avcDecoderConfig must be an AVCDecoderConfigurationRecord ' + '(configurationVersion 1).', + ); + } + _finished = true; + _color = color; + + final ftyp = _box('ftyp', [ + _fourCc('isom'), + _u32(0x200), + _fourCc('isom'), + _fourCc('iso2'), + _fourCc('avc1'), + _fourCc('mp41'), + ]); + + // The single chunk offset is known only once moov's size is known, and + // moov's size does not depend on the offset's value, so build moov twice. + final largeMdat = _payloadBytes + 8 > _maxUint32; + final mdatHeaderSize = largeMdat ? 16 : 8; + final moovSize = _moov(avcDecoderConfig, chunkOffset: 0).length; + final chunkOffset = ftyp.length + moovSize + mdatHeaderSize; + final moov = _moov(avcDecoderConfig, chunkOffset: chunkOffset); + assert(moov.length == moovSize); + + final out = BytesBuilder(copy: false) + ..add(ftyp) + ..add(moov); + if (largeMdat) { + out + ..add(_u32(1)) + ..add(_fourCc('mdat')) + ..add(_u64(_payloadBytes + 16)); + } else { + out + ..add(_u32(_payloadBytes + 8)) + ..add(_fourCc('mdat')); + } + for (final sample in _samples) { + out.add(sample); + } + return out.takeBytes(); + } + + Uint8List _moov(Uint8List avcC, {required int chunkOffset}) { + // Durations past 32 bits need the version-1 time boxes. + final longDuration = _totalDuration > _maxUint32; + return _box('moov', [ + _mvhd(longDuration), + _box('trak', [ + _tkhd(longDuration), + _box('mdia', [ + _mdhd(longDuration), + _fullBox('hdlr', 0, 0, [ + _u32(0), // pre_defined + _fourCc('vide'), + Uint8List(12), // reserved + Uint8List.fromList([...'VideoHandler'.codeUnits, 0]), + ]), + _box('minf', [ + _fullBox('vmhd', 0, 1, [Uint8List(8)]), // graphicsmode, opcolor + _box('dinf', [ + _fullBox('dref', 0, 0, [ + _u32(1), + _fullBox('url ', 0, 1, const []), // media is in this file + ]), + ]), + _stbl(avcC, chunkOffset), + ]), + ]), + ]), + ]); + } + + Uint8List _mvhd(bool longDuration) => + _fullBox('mvhd', longDuration ? 1 : 0, 0, [ + ..._times(longDuration), + _u32(timescale), + _duration(longDuration), + _u32(0x00010000), // rate 1.0 + _u16(0x0100), // volume 1.0 + Uint8List(10), // reserved + _identityMatrix(), + Uint8List(24), // pre_defined + _u32(2), // next_track_ID + ]); + + Uint8List _tkhd(bool longDuration) => + _fullBox('tkhd', longDuration ? 1 : 0, 0x000003, [ + // flags: track enabled | track in movie + ..._times(longDuration), + _u32(1), // track_ID + _u32(0), // reserved + _duration(longDuration), + Uint8List(8), // reserved + _u16(0), // layer + _u16(0), // alternate_group + _u16(0), // volume: 0 for video + _u16(0), // reserved + _identityMatrix(), + _u32(width * 0x10000), // 16.16 fixed point + _u32(height * 0x10000), + ]); + + Uint8List _mdhd(bool longDuration) => + _fullBox('mdhd', longDuration ? 1 : 0, 0, [ + ..._times(longDuration), + _u32(timescale), + _duration(longDuration), + _u16(0x55C4), // language 'und' + _u16(0), // pre_defined + ]); + + List _times(bool longDuration) => longDuration + ? [Uint8List(8), Uint8List(8)] // creation_time, modification_time + : [Uint8List(4), Uint8List(4)]; + + Uint8List _duration(bool longDuration) => + longDuration ? _u64(_totalDuration) : _u32(_totalDuration); + + Uint8List _stbl(Uint8List avcC, int chunkOffset) { + // Run-length encode the per-sample durations. + final sttsEntries = []; + var runCount = 0; + var runDelta = _durations.first; + for (final delta in _durations) { + if (delta == runDelta) { + runCount++; + continue; + } + sttsEntries + ..add(_u32(runCount)) + ..add(_u32(runDelta)); + runDelta = delta; + runCount = 1; + } + sttsEntries + ..add(_u32(runCount)) + ..add(_u32(runDelta)); + + final sizes = ByteData(_samples.length * 4); + for (var i = 0; i < _samples.length; i++) { + sizes.setUint32(i * 4, _samples[i].length); + } + + final allKeyFrames = _keyFrameNumbers.length == _samples.length; + return _box('stbl', [ + _fullBox('stsd', 0, 0, [_u32(1), _avc1(avcC)]), + _fullBox('stts', 0, 0, [_u32(sttsEntries.length ~/ 2), ...sttsEntries]), + // Without stss every sample is a sync sample. + if (!allKeyFrames) + _fullBox('stss', 0, 0, [ + _u32(_keyFrameNumbers.length), + for (final number in _keyFrameNumbers) _u32(number), + ]), + _fullBox('stsc', 0, 0, [ + _u32(1), + _u32(1), // first_chunk + _u32(_samples.length), // samples_per_chunk + _u32(1), // sample_description_index + ]), + _fullBox('stsz', 0, 0, [ + _u32(0), // sample_size: sizes vary + _u32(_samples.length), + sizes.buffer.asUint8List(), + ]), + // One chunk, and it starts right after ftyp and moov, so its offset + // always fits in 32 bits. + _fullBox('stco', 0, 0, [_u32(1), _u32(chunkOffset)]), + ]); + } + + Uint8List _avc1(Uint8List avcC) => _box('avc1', [ + Uint8List(6), // reserved + _u16(1), // data_reference_index + Uint8List(16), // pre_defined, reserved, pre_defined[3] + _u16(width), + _u16(height), + _u32(0x00480000), // 72 dpi + _u32(0x00480000), + _u32(0), // reserved + _u16(1), // frame_count + Uint8List(32), // compressorname + _u16(0x0018), // depth + _u16(0xFFFF), // pre_defined = -1 + _box('avcC', [avcC]), + if (_color case final color?) + _box('colr', [ + _fourCc('nclx'), + _u16(color.primaries), + _u16(color.transfer), + _u16(color.matrix), + Uint8List.fromList([color.fullRange ? 0x80 : 0]), + ]), + ]); + + static Uint8List _identityMatrix() => Uint8List.fromList([ + ..._u32(0x00010000), ..._u32(0), ..._u32(0), // + ..._u32(0), ..._u32(0x00010000), ..._u32(0), // + ..._u32(0), ..._u32(0), ..._u32(0x40000000), // + ]); +} + +const int _maxUint32 = 0xFFFFFFFF; + +Uint8List _box(String type, List children) { + var size = 8; + for (final child in children) { + size += child.length; + } + final out = BytesBuilder(copy: false) + ..add(_u32(size)) + ..add(_fourCc(type)); + for (final child in children) { + out.add(child); + } + return out.takeBytes(); +} + +Uint8List _fullBox( + String type, + int version, + int flags, + List children, +) => + _box(type, [ + _u32(version * 0x1000000 + flags), + ...children, + ]); + +Uint8List _fourCc(String code) { + assert(code.length == 4); + return Uint8List.fromList(code.codeUnits); +} + +Uint8List _u16(int value) => + (ByteData(2)..setUint16(0, value)).buffer.asUint8List(); + +Uint8List _u32(int value) => + (ByteData(4)..setUint32(0, value)).buffer.asUint8List(); + +/// ByteData.setUint64 is unsupported when compiled to JavaScript. +Uint8List _u64(int value) => (ByteData(8) + ..setUint32(0, value ~/ 0x100000000) + ..setUint32(4, value % 0x100000000)) + .buffer + .asUint8List(); diff --git a/lib/services/video_export/video_export_errors.dart b/lib/services/video_export/video_export_errors.dart new file mode 100644 index 00000000..a23150db --- /dev/null +++ b/lib/services/video_export/video_export_errors.dart @@ -0,0 +1,11 @@ +/// Thrown when the user cancels a video export. +class VideoExportCancelled implements Exception {} + +/// A video export failure whose [message] can be shown to the user. +class VideoExportException implements Exception { + VideoExportException(this.message); + final String message; + + @override + String toString() => 'VideoExportException: $message'; +} diff --git a/lib/services/video_export/video_export_source.dart b/lib/services/video_export/video_export_source.dart new file mode 100644 index 00000000..5b073d0e --- /dev/null +++ b/lib/services/video_export/video_export_source.dart @@ -0,0 +1,216 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:icarus/services/local_image_file.dart'; +import 'package:icarus/services/video_export/video_export_errors.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +/// A page the export dialog offers. +typedef VideoExportPageChoice = ({String id, String name}); + +/// The open strategy's pages in order, from wherever the strategy lives. +List videoExportPageChoices(WidgetRef ref) { + final strategy = ref.read(strategyProvider); + return switch (strategy.source) { + StrategySource.cloud => [ + for (final page in [ + ...?ref.read(remoteEditorSnapshotProvider).valueOrNull?.pages, + ]..sortBySortIndex((page) => page.sortIndex)) + (id: page.publicId, name: page.name), + ], + StrategySource.local => [ + for (final page in [ + ...?Hive.box(HiveBoxNames.strategiesBox) + .get(strategy.strategyId) + ?.pages, + ]..sortBySortIndex((page) => page.sortIndex)) + (id: page.id, name: page.name), + ], + null => const [], + }; +} + +/// This device's changes to a cloud strategy did not reach the server in +/// time, so an export would leave out work the user can see. +class VideoExportNotSynced implements Exception { + const VideoExportNotSynced(); + + String get userMessage => + "Your latest changes haven't synced yet. Export once the strategy " + 'shows as synced.'; + + @override + String toString() => 'VideoExportNotSynced'; +} + +/// A selected page is gone from the strategy (a teammate deleted it), so +/// the video would not be the one the user asked for. +class VideoExportPagesChanged implements Exception { + const VideoExportPagesChanged(); + + String get userMessage => + 'A page you selected was deleted. Check the pages and export again.'; + + @override + String toString() => 'VideoExportPagesChanged'; +} + +/// The strategy a video export renders, saved and whole, with its images +/// decoded. Release [images] once the export is done. +typedef VideoExportSource = ({StrategyData strategy, CaptureImages images}); + +/// How long a cloud export waits for this device's changes to land. +const videoExportSyncTimeout = Duration(seconds: 20); + +/// Saves the open strategy and reads it back whole for [pageIds]. +/// +/// A local strategy is saved and read back from the library. A cloud +/// strategy's other pages live only on the server, so the export sends the +/// open page's edits, waits for everything this device still has to send +/// for the strategy to land, then reads the whole strategy from the server. +/// +/// Throws [VideoExportNotSynced] when this device's work does not land +/// within [videoExportSyncTimeout], [VideoExportPagesChanged] when a page in +/// [pageIds] no longer exists, [CaptureImagesUnavailable] when an image on +/// the exported pages cannot be loaded, and [VideoExportCancelled] once +/// [isCancelled] turns true. +Future loadVideoExportSource( + WidgetRef ref, { + required Set pageIds, + bool Function() isCancelled = _never, +}) async { + void checkCancelled() { + if (isCancelled()) throw VideoExportCancelled(); + } + + final state = ref.read(strategyProvider); + final strategyId = state.strategyId; + if (strategyId == null) { + throw StateError('No strategy is open to export.'); + } + // A signed-out reader's only access is the link they opened. + final linkView = ref.read(shareLinkViewProvider); + final shareToken = + linkView?.strategyPublicId == strategyId ? linkView!.token : null; + + final StrategyData strategy; + final Map assets; + switch (state.source) { + case StrategySource.cloud: + // Queues the open page's edits and sends them. + await ref + .read(strategyPageSessionProvider.notifier) + .flushCurrentPage(flushImmediately: true); + checkCancelled(); + await _waitUntilSent(ref, strategyId, isCancelled: isCancelled); + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchFullSnapshot(strategyId, shareToken: shareToken); + checkCancelled(); + strategy = + StrategyImportExportService.strategyDataFromRemoteSnapshot(snapshot); + assets = snapshot.assetsById; + case StrategySource.local || null: + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + checkCancelled(); + final saved = + Hive.box(HiveBoxNames.strategiesBox).get(strategyId); + if (saved == null) { + throw StateError('Strategy $strategyId is not in the library.'); + } + strategy = saved; + assets = const {}; + } + + final pages = [ + for (final page in strategy.pages) + if (pageIds.contains(page.id)) page, + ]; + if (pages.length != pageIds.length) { + throw const VideoExportPagesChanged(); + } + + final isCloud = state.source == StrategySource.cloud; + final images = await resolveCaptureImages( + { + for (final page in pages) + for (final image in page.imageData) + image.id: resolveStrategyImageSource( + localFilePath: findLocalImageFile( + storageDirectory: state.storageDirectory, + imageId: image.id, + fileExtension: image.fileExtension, + ), + isCloudStrategy: isCloud, + // The whole strategy was just read, and this device has + // nothing left to upload. + assetsLoaded: true, + remoteAsset: assets[image.id], + uploadMayBeQueuedHere: false, + ), + }, + fetch: (imageId, url, client) => downloadCloudImageBytes( + url, + client: client, + freshUrl: () => + ref.read(convexStrategyRepositoryProvider).getImageAssetUrl( + strategyPublicId: strategyId, + assetPublicId: imageId, + shareToken: shareToken, + ), + ), + checkpoint: checkCancelled, + ); + return (strategy: strategy, images: images); +} + +bool _never() => false; + +/// Waits until this device has nothing left to send for [strategyId]: no +/// op queued, in flight, or held for review, and no image uploading. Read +/// from the queues at each check: a save with nothing to send changes no +/// queue state, so nothing listening would hear it finish. +Future _waitUntilSent( + WidgetRef ref, + String strategyId, { + required bool Function() isCancelled, +}) async { + bool sent() { + final ops = ref.read(strategyOpQueueProvider); + final media = ref.read(cloudMediaUploadQueueProvider); + // An outbox with records it could not read cannot say they landed. + return ops.outboxIsReliable && + media.outboxIsReliable && + ops.pending.isEmpty && + !ops.isFlushing && + media.jobsForStrategy(strategyId).isEmpty; + } + + const step = Duration(milliseconds: 100); + var waited = Duration.zero; + while (!sent()) { + if (isCancelled()) throw VideoExportCancelled(); + // Work held for the user's review never lands by waiting. + if (ref.read(strategyOpQueueProvider).needsAttention) { + throw const VideoExportNotSynced(); + } + if (waited >= videoExportSyncTimeout) throw const VideoExportNotSynced(); + await Future.delayed(step); + waited += step; + } +} diff --git a/lib/services/video_export/video_exporter.dart b/lib/services/video_export/video_exporter.dart index 11d04e43..966e8607 100644 --- a/lib/services/video_export/video_exporter.dart +++ b/lib/services/video_export/video_exporter.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -10,6 +10,7 @@ import 'package:icarus/page_transition/agent_path.dart'; import 'package:icarus/page_transition/navigation_geometry_map.dart'; import 'package:icarus/page_transition/transition_planner.dart'; import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/transition_provider.dart'; @@ -17,12 +18,11 @@ import 'package:icarus/screenshot/offscreen_capture.dart'; import 'package:icarus/screenshot/capture_geometry.dart'; import 'package:icarus/screenshot/persistent_offscreen_renderer.dart'; import 'package:icarus/screenshot/screenshot_view.dart'; -import 'package:icarus/services/video_export/ffmpeg_png_sequence_writer.dart'; import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; import 'package:icarus/widgets/page_transition_overlay.dart'; -import 'package:path/path.dart' as p; @visibleForTesting Future runPreservingScreenshotMode( @@ -46,6 +46,7 @@ class VideoExporter { required this.strategyState, required this.mapState, required this.geometry, + this.imageSources = const {}, this.navigation, this.requireNavigation = false, }); @@ -54,6 +55,10 @@ class VideoExporter { final StrategyState strategyState; final MapState mapState; final VisionGeometryMap? geometry; + + /// What each image on the exported pages paints, by image id; see + /// [captureImageSourcesProvider]. + final Map imageSources; final NavigationGeometryMap? navigation; final bool requireNavigation; @@ -80,22 +85,20 @@ class VideoExporter { static const double _renderWeight = 0.85; bool _cancelled = false; - final FfmpegVideoEncoder _encoder = FfmpegVideoEncoder(); - FfmpegPngSequenceWriter? _frameWriter; + VideoFrameSink? _sink; void cancel() { _cancelled = true; - // export()'s cleanup awaits the writer's termination; here we only need - // to interrupt the processes. - unawaited(_frameWriter?.cancel()); - _encoder.cancel(); + _sink?.cancel(); } - Future export({ + /// Renders [pages] into [sink]. Returns the finished video when [sink] + /// keeps it in memory (the browser), or null once it is written to its + /// file (desktop). + Future export({ required List pages, required Duration stepDuration, - required String ffmpegBinary, - required String outputPath, + required VideoFrameSink sink, required VideoExportQuality quality, void Function(double fraction, String label)? onProgress, }) async { @@ -119,23 +122,21 @@ class VideoExporter { } } - await runPreservingScreenshotMode( + return runPreservingScreenshotMode( () => _export( pages: pages, stepDuration: stepDuration, - ffmpegBinary: ffmpegBinary, - outputPath: outputPath, + sink: sink, quality: quality, onProgress: onProgress, ), ); } - Future _export({ + Future _export({ required List pages, required Duration stepDuration, - required String ffmpegBinary, - required String outputPath, + required VideoFrameSink sink, required VideoExportQuality quality, void Function(double fraction, String label)? onProgress, }) async { @@ -150,15 +151,12 @@ class VideoExporter { fps: fps, ); - final tempDir = await Directory.systemTemp.createTemp( - 'icarus_video_export_', - ); ProviderContainer? captureContainer; CaptureGeometryLease? captureGeometry; PersistentOffscreenRenderer? renderer; - FfmpegPngSequenceWriter? frameWriter; + _sink = sink; try { - final offscreenContainer = ProviderContainer(); + final offscreenContainer = createCaptureContainer(images: imageSources); captureContainer = offscreenContainer; onProgress?.call(0, 'Preparing map'); captureGeometry = await prepareCaptureGeometry( @@ -173,20 +171,13 @@ class VideoExporter { wrapWidget: (child) => wrapForOffscreenCapture(child, container: offscreenContainer), ); - frameWriter = FfmpegPngSequenceWriter(); - _frameWriter = frameWriter; - await frameWriter.start( - binary: ffmpegBinary, - workingDirectory: tempDir.path, + await sink.start( width: CoordinateSystem.screenShotSize.width.round(), height: CoordinateSystem.screenShotSize.height.round(), - fps: fps, totalFrames: totalFrames, + totalSeconds: totalSeconds, ); - final concatLines = ['ffconcat version 1.0']; - var frameIndex = 0; var renderedFrames = 0; - String? lastFrameFile; Future renderFrame( ScreenshotView view, @@ -195,13 +186,7 @@ class VideoExporter { if (_cancelled) throw VideoExportCancelled(); view.hydrateProviders(offscreenContainer); final bytes = await renderer!.captureRawRgba(view); - final fileName = 'frame_${frameIndex.toString().padLeft(5, '0')}.png'; - await frameWriter!.writeFrame(bytes); - frameIndex++; - concatLines - ..add("file '$fileName'") - ..add('duration ${durationSeconds.toStringAsFixed(6)}'); - lastFrameFile = fileName; + await sink.addFrame(bytes, durationSeconds); renderedFrames++; onProgress?.call( renderedFrames / totalFrames * _renderWeight, @@ -264,47 +249,22 @@ class VideoExporter { } } - await frameWriter.finish(); - _frameWriter = null; - - // The concat demuxer ignores the duration of the final entry unless the - // last file is repeated. - if (lastFrameFile != null) { - concatLines.add("file '$lastFrameFile'"); - } - final concatFile = File(p.join(tempDir.path, 'frames.ffconcat')); - await concatFile.writeAsString(concatLines.join('\n')); - if (_cancelled) throw VideoExportCancelled(); onProgress?.call(_renderWeight, 'Encoding video'); - await _encoder.encode( - binary: ffmpegBinary, - workingDirectory: tempDir.path, - concatListFileName: 'frames.ffconcat', - outputPath: outputPath, - totalSeconds: totalSeconds, - quality: quality, + return await sink.finish( onProgress: (fraction) => onProgress?.call( _renderWeight + fraction * (1 - _renderWeight), 'Encoding video', ), ); } finally { - // Wait for ffmpeg to exit before deleting the directory it writes into; - // on Windows the delete races a still-exiting process otherwise. - await frameWriter?.cancel(); - _frameWriter = null; + _sink = null; try { + await sink.close(); await renderer?.dispose(); } finally { captureGeometry?.close(); captureContainer?.dispose(); - try { - await tempDir.delete(recursive: true); - } on Object { - // Leaving orphaned temp frames behind is preferable to masking the - // original export result. - } } } } diff --git a/lib/services/video_export/video_frame_sink.dart b/lib/services/video_export/video_frame_sink.dart new file mode 100644 index 00000000..4d0353b4 --- /dev/null +++ b/lib/services/video_export/video_frame_sink.dart @@ -0,0 +1,32 @@ +import 'dart:typed_data'; + +/// Where [VideoExporter] sends the frames it renders: an encoder that turns +/// them into an .mp4. Desktop pipes them to the bundled ffmpeg +/// (`FfmpegVideoSink`); the browser encodes them with WebCodecs +/// (`createBrowserVideoSink`). +abstract interface class VideoFrameSink { + /// Called once, before the first frame. Every frame is [width] x [height]; + /// the video runs [totalSeconds] over [totalFrames] frames. + Future start({ + required int width, + required int height, + required int totalFrames, + required double totalSeconds, + }); + + /// One rendered frame, top-to-bottom RGBA, shown for [durationSeconds]. + Future addFrame(Uint8List rgba, double durationSeconds); + + /// Encodes every frame added. Returns the finished video when this sink + /// keeps it in memory (the browser), or null once it is written to its + /// file (desktop). [onProgress] reports 0 to 1. + Future finish({void Function(double fraction)? onProgress}); + + /// Stops the encode in flight; [addFrame] and [finish] then throw + /// `VideoExportCancelled`. + void cancel(); + + /// Releases everything the sink holds. Safe after [finish], after + /// [cancel], and after a failure. + Future close(); +} diff --git a/lib/services/video_export/web_codecs_video_encoder.dart b/lib/services/video_export/web_codecs_video_encoder.dart new file mode 100644 index 00000000..4f1adfd5 --- /dev/null +++ b/lib/services/video_export/web_codecs_video_encoder.dart @@ -0,0 +1,609 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:icarus/services/video_export/mp4_muxer.dart'; +import 'package:icarus/services/video_export/video_export_errors.dart'; + +/// Encodes rendered frames into an H.264 .mp4 in the browser, using WebCodecs +/// `VideoEncoder` and [Mp4H264Muxer]. The web counterpart of the desktop +/// ffmpeg pipeline, which browsers cannot run. +/// +/// The output is constant frame rate: each [addFrame] is repeated at `fps` +/// for its duration. A repeat reuses the frame's pixels, so a held page costs +/// one RGBA upload however long it is shown. +class WebCodecsMp4Encoder { + /// Keyframe spacing ceiling, so players can seek within long holds. + static const _maxKeyFrameIntervalSeconds = 2; + + /// Encoder queue depth at which [addFrame] waits for the encoder to catch + /// up, so a fast renderer cannot pile up frames in memory. + static const _maxEncodeQueueSize = 8; + + /// Whether this browser can encode H.264 at this size, frame rate, and + /// bitrate. False when WebCodecs is missing entirely. + static Future isSupported({ + required int width, + required int height, + required int fps, + int? bitrate, + }) async => + await _supportedConfig( + width: width, + height: height, + fps: fps, + bitrate: bitrate, + ) != + null; + + _VideoEncoder? _encoder; + _OffscreenCanvas? _scaleCanvas; + _Canvas2D? _scaleContext; + late int _inputWidth; + late int _inputHeight; + late int _outputWidth; + late int _outputHeight; + late int _fps; + + /// Frames handed to the encoder so far; frame i is shown at i / fps. + int _framesEncoded = 0; + int _framesSinceKeyFrame = 0; + + /// Sum of every [addFrame] duration, so rounding never accumulates. + int _plannedMicros = 0; + + final List<_EncodedSample> _samples = []; + Uint8List? _avcDecoderConfig; + Mp4ColorInfo? _color; + Completer? _dequeued; + + /// Why the encoder failed, reported by the next call. + String? _failure; + bool _cancelled = false; + bool _finished = false; + + /// Configures the encoder. Frames arrive at [inputWidth] x [inputHeight] + /// and are scaled to [outputWidth] x [outputHeight] when those differ. + /// A null [bitrate] encodes at a high variable bitrate (see + /// [_defaultBitrate]); otherwise the bitrate is held constant. + Future start({ + required int inputWidth, + required int inputHeight, + required int outputWidth, + required int outputHeight, + required int fps, + int? bitrate, + }) async { + if (_encoder != null) throw StateError('The encoder is already started.'); + if (fps <= 0) throw ArgumentError.value(fps, 'fps', 'must be positive'); + _inputWidth = inputWidth; + _inputHeight = inputHeight; + _outputWidth = outputWidth; + _outputHeight = outputHeight; + _fps = fps; + + final config = await _supportedConfig( + width: outputWidth, + height: outputHeight, + fps: fps, + bitrate: bitrate, + ); + if (_cancelled) throw VideoExportCancelled(); + if (config == null) { + throw VideoExportException( + 'This browser cannot encode H.264 video at ' + '${outputWidth}x$outputHeight, $fps fps.', + ); + } + + if (outputWidth != inputWidth || outputHeight != inputHeight) { + final canvas = _OffscreenCanvas(outputWidth, outputHeight); + final context = canvas.getContext('2d'); + if (context == null) { + throw VideoExportException('This browser cannot scale video frames.'); + } + context.imageSmoothingQuality = 'high'; + _scaleCanvas = canvas; + _scaleContext = context; + } + + final encoder = _VideoEncoder( + _VideoEncoderInit( + output: _onChunk.toJS, + error: _onError.toJS, + ), + ); + encoder.ondequeue = ((JSAny? _) => _wakeWaiter()).toJS; + _encoder = encoder; + try { + encoder.configure(config); + } on Object catch (error) { + _closeEncoder(); + throw VideoExportException('Could not start the video encoder: $error'); + } + } + + /// Adds one rendered frame (top-to-bottom RGBA, inputWidth * inputHeight * 4 + /// bytes) shown for [duration]. + /// + /// The frame is repeated for round(duration * fps) ticks, at least one, + /// measured against the running total so remainders carry forward and the + /// video's length matches the sum of the durations. + Future addFrame(Uint8List rgba, {required Duration duration}) async { + final encoder = _checkRunning(); + if (rgba.length != _inputWidth * _inputHeight * 4) { + throw ArgumentError( + 'Expected ${_inputWidth * _inputHeight * 4} RGBA bytes, ' + 'got ${rgba.length}.', + ); + } + if (duration.isNegative) { + throw ArgumentError.value(duration, 'duration', 'must not be negative'); + } + _plannedMicros += duration.inMicroseconds; + final plannedFrames = + (_plannedMicros * _fps / Duration.microsecondsPerSecond).round(); + final repeats = math.max(1, plannedFrames - _framesEncoded); + + final base = _baseFrame(rgba); + try { + for (var i = 0; i < repeats; i++) { + while (encoder.encodeQueueSize > _maxEncodeQueueSize) { + final dequeued = _dequeued ??= Completer(); + await dequeued.future; + _checkRunning(); + } + final index = _framesEncoded; + final timestamp = _timestampOf(index); + final frame = _VideoFrame( + base, + _VideoFrameInit( + timestamp: timestamp, + duration: _timestampOf(index + 1) - timestamp, + ), + ); + // A held page (a run longer than one frame) opens on a keyframe so it + // starts crisp and seeks cleanly. Single-frame runs are transition + // frames; keying each of them would spend the bitrate on I-frames. + final keyFrame = index == 0 || + (i == 0 && repeats > 1) || + _framesSinceKeyFrame >= _maxKeyFrameIntervalSeconds * _fps; + try { + encoder.encode(frame, _EncodeOptions(keyFrame: keyFrame)); + } on Object catch (error) { + _failure ??= '$error'; + _checkRunning(); + } finally { + frame.close(); + } + _framesEncoded++; + _framesSinceKeyFrame = keyFrame ? 1 : _framesSinceKeyFrame + 1; + } + } finally { + base.close(); + } + _checkRunning(); + } + + /// Flushes the encoder and returns the finished .mp4 bytes. + Future finish() async { + final encoder = _checkRunning(); + try { + await encoder.flush().toDart; + } on Object catch (error) { + _failure ??= '$error'; + } + _checkRunning(); + _finished = true; + _closeEncoder(); + + final config = _avcDecoderConfig; + if (_samples.isEmpty || config == null) { + throw VideoExportException('The video encoder produced no video.'); + } + final ticksPerFrame = _timescale ~/ _fps; + final muxer = Mp4H264Muxer( + width: _outputWidth, + height: _outputHeight, + timescale: _timescale, + ); + for (var i = 0; i < _samples.length; i++) { + final sample = _samples[i]; + final nextFrame = + i + 1 < _samples.length ? _samples[i + 1].frame : _framesEncoded; + // The MP4 has no composition offsets, so chunks must arrive in + // presentation order. Encoders that reorder (B-frames) fail loudly + // rather than produce a video that plays frames out of order. + if (nextFrame <= sample.frame) { + throw VideoExportException( + 'The video encoder returned frames out of order.', + ); + } + muxer.addSample( + sample.bytes, + duration: (nextFrame - sample.frame) * ticksPerFrame, + isKeyFrame: sample.isKeyFrame, + ); + } + return muxer.finish(avcDecoderConfig: config, color: _color); + } + + /// Stops encoding and releases the encoder. Safe to call at any time and + /// more than once; later [addFrame] and [finish] calls throw + /// [VideoExportCancelled]. + void cancel() { + _cancelled = true; + _closeEncoder(); + _wakeWaiter(); + } + + /// Media ticks per second. 1000 ticks per frame keeps every frame duration + /// an exact integer. + int get _timescale => _fps * 1000; + + int _timestampOf(int frame) => + (frame * Duration.microsecondsPerSecond / _fps).round(); + + _VideoFrame _baseFrame(Uint8List rgba) { + final raw = _VideoFrame( + rgba.toJS, + _VideoFrameBufferInit( + format: 'RGBA', + codedWidth: _inputWidth, + codedHeight: _inputHeight, + timestamp: 0, + ), + ); + final context = _scaleContext; + if (context == null) return raw; + try { + context.drawImage(raw, 0, 0, _outputWidth, _outputHeight); + } finally { + raw.close(); + } + return _VideoFrame(_scaleCanvas!, _VideoFrameInit(timestamp: 0)); + } + + _VideoEncoder _checkRunning() { + if (_cancelled) throw VideoExportCancelled(); + final failure = _failure; + if (failure != null) { + _closeEncoder(); + throw VideoExportException('The video encoder failed: $failure'); + } + final encoder = _encoder; + if (encoder == null || _finished) { + throw StateError( + _finished ? 'The video is already finished.' : 'Call start() first.', + ); + } + return encoder; + } + + void _onChunk(_EncodedVideoChunk chunk, _ChunkMetadata? metadata) { + try { + final description = metadata?.decoderConfig?.description; + if (description != null) { + final config = _copyBufferSource(description); + final previous = _avcDecoderConfig; + if (previous != null && !_sameBytes(previous, config)) { + // The MP4 carries one avcC, which every sample must decode with. + throw StateError('The encoder changed its H.264 parameter sets.'); + } + _avcDecoderConfig = config; + } + if (metadata?.decoderConfig?.colorSpace case final colorSpace?) { + _color = _mp4Color(colorSpace); + } + final bytes = _Uint8Array(chunk.byteLength); + chunk.copyTo(bytes); + _samples.add( + _EncodedSample( + frame: + (chunk.timestamp * _fps / Duration.microsecondsPerSecond).round(), + bytes: bytes.toDart, + isKeyFrame: chunk.type == 'key', + ), + ); + } on Object catch (error) { + _failure ??= '$error'; + _closeEncoder(); + _wakeWaiter(); + } + } + + void _onError(JSAny? error) { + _failure ??= _jsString(error); + _wakeWaiter(); + } + + void _wakeWaiter() { + final waiter = _dequeued; + _dequeued = null; + if (waiter != null && !waiter.isCompleted) waiter.complete(); + } + + void _closeEncoder() { + final encoder = _encoder; + if (encoder == null || encoder.state == 'closed') return; + try { + encoder.close(); + } on Object { + // Already closed by an encoder error. + } + } + + /// Picks a bitrate for [bitrate] == null: 8 Mbps at 1080p30, the desktop + /// Max preset's H.264 rate, scaled by pixel count. 60 fps gets 1.5x rather + /// than 2x: consecutive frames differ less, and held pages cost almost + /// nothing either way. + static int _defaultBitrate(int width, int height, int fps) { + final pixelScale = width * height / (1920 * 1080); + final rateScale = fps > 30 ? 1.5 : 1.0; + return math.max(1000000, (8000000 * pixelScale * rateScale).round()); + } + + /// The first H.264 profile this browser accepts at this size, or null. + /// High needs a hardware encoder in Chrome; its software encoder (OpenH264) + /// only does Constrained Baseline, so fall back through Main to that. + static Future<_VideoEncoderConfig?> _supportedConfig({ + required int width, + required int height, + required int fps, + int? bitrate, + }) async { + if (!globalContext.has('VideoEncoder')) return null; + final targetBitrate = bitrate ?? _defaultBitrate(width, height, fps); + final level = _h264Level(width, height, fps, targetBitrate); + if (level == null) return null; + for (final profile in _h264Profiles) { + final config = _VideoEncoderConfig( + codec: 'avc1.$profile${level.toRadixString(16).padLeft(2, '0')}', + width: width, + height: height, + bitrate: targetBitrate, + bitrateMode: bitrate != null ? 'constant' : 'variable', + framerate: fps, + avc: _AvcEncoderConfig(format: 'avc'), + latencyMode: 'quality', + ); + try { + final support = await _VideoEncoder.isConfigSupported(config).toDart; + if (support.supported ?? false) return config; + } on Object { + // A rejected (TypeError) config means this profile is not usable. + } + } + return null; + } + + /// profile_idc and constraint flags, in preference order: High, Main, + /// Constrained Baseline. + static const _h264Profiles = ['6400', '4d00', '42e0']; + + /// The lowest H.264 level whose frame size, macroblock rate, and bitrate + /// limits hold this stream, or null when none does. + static int? _h264Level(int width, int height, int fps, int bitrate) { + final macroblocks = ((width + 15) ~/ 16) * ((height + 15) ~/ 16); + final macroblocksPerSecond = macroblocks * fps; + // (level_idc, MaxFS, MaxMBPS, MaxBR in kbit/s for Baseline/Main; High + // allows 1.25x, so these limits hold for every profile above.) + const levels = [ + (30, 1620, 40500, 10000), + (31, 3600, 108000, 14000), + (32, 5120, 216000, 20000), + (40, 8192, 245760, 20000), + (41, 8192, 245760, 50000), + (42, 8704, 522240, 50000), + (50, 22080, 589824, 135000), + (51, 36864, 983040, 240000), + (52, 36864, 2073600, 240000), + ]; + for (final (idc, maxFs, maxMbps, maxBrKbps) in levels) { + if (macroblocks <= maxFs && + macroblocksPerSecond <= maxMbps && + bitrate <= maxBrKbps * 1000) { + return idc; + } + } + return null; + } +} + +class _EncodedSample { + _EncodedSample({ + required this.frame, + required this.bytes, + required this.isKeyFrame, + }); + + /// Index of the frame this chunk encodes, from its timestamp. + final int frame; + final Uint8List bytes; + final bool isKeyFrame; +} + +bool _sameBytes(Uint8List a, Uint8List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +/// Copies a WebCodecs BufferSource (an ArrayBuffer or a view of one). +Uint8List _copyBufferSource(JSObject source) { + final bytes = source.isA() + ? _Uint8Array.view(source, 0, (source as _ArrayBuffer).byteLength) + : _Uint8Array.view( + (source as _ArrayBufferView).buffer, + source.byteOffset, + source.byteLength, + ); + return Uint8List.fromList(bytes.toDart); +} + +@JS('String') +external String _jsString(JSAny? value); + +@JS('VideoEncoder') +extension type _VideoEncoder._(JSObject _) implements JSObject { + external factory _VideoEncoder(_VideoEncoderInit init); + + external static JSPromise<_VideoEncoderSupport> isConfigSupported( + _VideoEncoderConfig config, + ); + + external String get state; + external int get encodeQueueSize; + external set ondequeue(JSFunction? handler); + + external void configure(_VideoEncoderConfig config); + external void encode(_VideoFrame frame, _EncodeOptions options); + external JSPromise flush(); + external void close(); +} + +extension type _VideoEncoderInit._(JSObject _) implements JSObject { + external factory _VideoEncoderInit({JSFunction output, JSFunction error}); +} + +extension type _VideoEncoderConfig._(JSObject _) implements JSObject { + external factory _VideoEncoderConfig({ + String codec, + int width, + int height, + int bitrate, + String bitrateMode, + num framerate, + _AvcEncoderConfig avc, + String latencyMode, + }); +} + +extension type _AvcEncoderConfig._(JSObject _) implements JSObject { + external factory _AvcEncoderConfig({String format}); +} + +extension type _VideoEncoderSupport._(JSObject _) implements JSObject { + external bool? get supported; +} + +extension type _EncodeOptions._(JSObject _) implements JSObject { + external factory _EncodeOptions({bool keyFrame}); +} + +@JS('VideoFrame') +extension type _VideoFrame._(JSObject _) implements JSObject { + /// [source] is a BufferSource with a [_VideoFrameBufferInit], or an image + /// source (VideoFrame, OffscreenCanvas) with a [_VideoFrameInit]. + external factory _VideoFrame(JSObject source, JSObject init); + + external void close(); +} + +extension type _VideoFrameBufferInit._(JSObject _) implements JSObject { + external factory _VideoFrameBufferInit({ + String format, + int codedWidth, + int codedHeight, + int timestamp, + }); +} + +extension type _VideoFrameInit._(JSObject _) implements JSObject { + external factory _VideoFrameInit({int timestamp, int duration}); +} + +extension type _EncodedVideoChunk._(JSObject _) implements JSObject { + external String get type; + external int get timestamp; + external int get byteLength; + external void copyTo(_Uint8Array destination); +} + +extension type _ChunkMetadata._(JSObject _) implements JSObject { + external _DecoderConfig? get decoderConfig; +} + +extension type _DecoderConfig._(JSObject _) implements JSObject { + external JSObject? get description; + external _VideoColorSpace? get colorSpace; +} + +extension type _VideoColorSpace._(JSObject _) implements JSObject { + external String? get primaries; + external String? get transfer; + external String? get matrix; + external bool? get fullRange; +} + +/// The colour the encoder says its samples carry, as MP4 code points. Hardware +/// encoders can write full-range samples without a colour description in the +/// bitstream; the container then carries it (see [Mp4ColorInfo]). Names the +/// table does not know are "unspecified" (2). +Mp4ColorInfo _mp4Color(_VideoColorSpace colorSpace) => Mp4ColorInfo( + primaries: switch (colorSpace.primaries) { + 'bt709' => 1, + 'bt470bg' => 5, + 'smpte170m' => 6, + 'bt2020' => 9, + 'smpte432' => 12, + _ => 2, + }, + transfer: switch (colorSpace.transfer) { + 'bt709' => 1, + 'smpte170m' => 6, + 'linear' => 8, + 'iec61966-2-1' => 13, + 'pq' => 16, + 'hlg' => 18, + _ => 2, + }, + matrix: switch (colorSpace.matrix) { + 'rgb' => 0, + 'bt709' => 1, + 'bt470bg' => 5, + 'smpte170m' => 6, + 'bt2020-ncl' => 9, + _ => 2, + }, + fullRange: colorSpace.fullRange ?? false, + ); + +@JS('OffscreenCanvas') +extension type _OffscreenCanvas._(JSObject _) implements JSObject { + external factory _OffscreenCanvas(int width, int height); + + external _Canvas2D? getContext(String contextId); +} + +extension type _Canvas2D._(JSObject _) implements JSObject { + external set imageSmoothingQuality(String value); + + external void drawImage( + JSObject image, + num dx, + num dy, + num dWidth, + num dHeight, + ); +} + +extension type _ArrayBuffer._(JSObject _) implements JSObject { + external int get byteLength; +} + +extension type _ArrayBufferView._(JSObject _) implements JSObject { + external JSObject get buffer; + external int get byteOffset; + external int get byteLength; +} + +@JS('Uint8Array') +extension type _Uint8Array._(JSUint8Array _) implements JSUint8Array { + external factory _Uint8Array(int length); + external factory _Uint8Array.view(JSObject buffer, int offset, int length); +} diff --git a/lib/share/current_share_origin.dart b/lib/share/current_share_origin.dart new file mode 100644 index 00000000..b10a4535 --- /dev/null +++ b/lib/share/current_share_origin.dart @@ -0,0 +1,12 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:icarus/share/share_link_format.dart'; + +/// The origin share links made on this device point at, and the one incoming +/// links may use besides production. +/// +/// On web this is the page's own origin, so the beta hands out beta links +/// (`https://beta.icarusstrats.com/share/…`) that open in the beta. Native +/// builds always use production. +Uri currentShareOrigin() { + return kIsWeb ? Uri.parse(Uri.base.origin) : icarusProductionShareOrigin; +} diff --git a/lib/share/pending_share_code_store.dart b/lib/share/pending_share_code_store.dart new file mode 100644 index 00000000..0ba50b56 --- /dev/null +++ b/lib/share/pending_share_code_store.dart @@ -0,0 +1,32 @@ +import 'package:icarus/share/pending_share_code_store_stub.dart' + if (dart.library.js_interop) 'package:icarus/share/pending_share_code_store_web.dart' + as platform; + +/// Holds a share code that is waiting for the user to sign in. +/// +/// On web, Discord sign-in navigates the tab away and back, which restarts +/// the app, so the code must outlive the page: the web store keeps it in the +/// tab's sessionStorage. Native sign-in never restarts the app, so memory is +/// enough there. +abstract interface class PendingShareCodeStore { + String? read(); + void write(String code); + void clear(); +} + +class MemoryPendingShareCodeStore implements PendingShareCodeStore { + String? _code; + + @override + String? read() => _code; + + @override + void write(String code) => _code = code; + + @override + void clear() => _code = null; +} + +/// sessionStorage on web, memory on native. +PendingShareCodeStore createPendingShareCodeStore() => + platform.createPendingShareCodeStore(); diff --git a/lib/share/pending_share_code_store_stub.dart b/lib/share/pending_share_code_store_stub.dart new file mode 100644 index 00000000..0ae9facd --- /dev/null +++ b/lib/share/pending_share_code_store_stub.dart @@ -0,0 +1,4 @@ +import 'package:icarus/share/pending_share_code_store.dart'; + +PendingShareCodeStore createPendingShareCodeStore() => + MemoryPendingShareCodeStore(); diff --git a/lib/share/pending_share_code_store_web.dart b/lib/share/pending_share_code_store_web.dart new file mode 100644 index 00000000..d603c646 --- /dev/null +++ b/lib/share/pending_share_code_store_web.dart @@ -0,0 +1,50 @@ +import 'dart:js_interop'; + +import 'package:icarus/share/pending_share_code_store.dart'; + +PendingShareCodeStore createPendingShareCodeStore() => + _SessionPendingShareCodeStore(); + +@JS('sessionStorage') +external _Storage get _sessionStorage; + +extension type _Storage._(JSObject _) implements JSObject { + external String? getItem(String key); + external void setItem(String key, String value); + external void removeItem(String key); +} + +/// Keeps the code in this tab's sessionStorage, which survives the Discord +/// round trip and dies with the tab. If the browser refuses storage (some +/// privacy modes throw on access), the code lives in memory for this page +/// only, which is how every build behaved before. +class _SessionPendingShareCodeStore implements PendingShareCodeStore { + static const _key = 'icarus.pendingShareCode'; + final _fallback = MemoryPendingShareCodeStore(); + + @override + String? read() { + try { + return _sessionStorage.getItem(_key); + } catch (_) { + return _fallback.read(); + } + } + + @override + void write(String code) { + try { + _sessionStorage.setItem(_key, code); + } catch (_) { + _fallback.write(code); + } + } + + @override + void clear() { + _fallback.clear(); + try { + _sessionStorage.removeItem(_key); + } catch (_) {} + } +} diff --git a/lib/share/share_link_copy.dart b/lib/share/share_link_copy.dart new file mode 100644 index 00000000..960a8674 --- /dev/null +++ b/lib/share/share_link_copy.dart @@ -0,0 +1,28 @@ +abstract final class ShareLinkCopy { + /// A strategy link opens read-only for anyone, account or not, where the + /// link opens in a browser: links made in the web app point at it. Links + /// made in the desktop app hand off to the app, which a recipient may not + /// have, and a folder link needs an account; neither promises a view. + static String dialogDescription( + String targetType, { + required bool opensInBrowser, + }) => + targetType == 'strategy' && opensInBrowser + ? 'Anyone with a link can view this strategy, no account needed. ' + 'Signing in joins it with the access you choose. Links stay ' + 'active until you disable them.' + : 'Links stay active until you disable them. Anyone who opens one ' + 'joins this item with the access you choose.'; + + static const disableTitle = 'Disable this link?'; + static const disableDescription = + 'Disabling this link prevents new people from joining and stops anyone ' + 'viewing through it without an account. People who already joined keep ' + 'their access.'; + static const disableAction = 'Disable link'; + static const disableFailure = 'Failed to disable share link.'; + + static const disabledStatus = 'DISABLED'; + static const disabledTooltip = 'Link disabled'; + static const disableTooltip = 'Disable link'; +} diff --git a/lib/share/share_link_format.dart b/lib/share/share_link_format.dart new file mode 100644 index 00000000..8d1ed756 --- /dev/null +++ b/lib/share/share_link_format.dart @@ -0,0 +1,113 @@ +import 'dart:math'; + +/// The production host. Native builds put it in every share link they make, +/// and every build recognizes it. +const icarusShareHost = 'icarusstrats.com'; +final Uri icarusProductionShareOrigin = Uri.https(icarusShareHost); + +const _shareCodePrefix = 'ICR'; +const _shareCodeAlphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; +final _shareCodePattern = RegExp( + r'^ICR-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$', +); + +/// The link for [code] on [origin], e.g. `https://icarusstrats.com/share/ICR-…`. +String buildIcarusShareLink(String code, {required Uri origin}) { + return origin.replace(pathSegments: ['share', code]).toString(); +} + +/// Whether [uri] is a share link: the `icarus://share` scheme, or a `/share/` +/// path on the production host or on [currentOrigin] (the web page's own +/// origin, so links made in the beta open in the beta). +bool isIcarusShareUri(Uri uri, {required Uri currentOrigin}) { + final bool hasSharePath; + try { + hasSharePath = + uri.pathSegments.any((segment) => segment.toLowerCase() == 'share'); + } on FormatException { + return false; // A path that does not decode is not a link we made. + } + + if (uri.scheme.toLowerCase() == 'icarus') { + return uri.host.toLowerCase() == 'share' || hasSharePath; + } + if (!hasSharePath) { + return false; + } + return _isProductionShareHost(uri) || _hasOrigin(uri, currentOrigin); +} + +bool _isProductionShareHost(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + return (scheme == 'https' || scheme == 'http') && + (host == icarusShareHost || host == 'www.$icarusShareHost'); +} + +bool _hasOrigin(Uri uri, Uri origin) { + return uri.host.isNotEmpty && + uri.scheme.toLowerCase() == origin.scheme.toLowerCase() && + uri.host.toLowerCase() == origin.host.toLowerCase() && + uri.port == origin.port; +} + +String generateIcarusShareCode({Random? random}) { + final source = random ?? Random.secure(); + final characters = List.generate( + 16, + (_) => _shareCodeAlphabet[source.nextInt(_shareCodeAlphabet.length)], + ); + + final grouped = []; + for (var index = 0; index < characters.length; index += 4) { + grouped.add(characters.sublist(index, index + 4).join()); + } + + return '$_shareCodePrefix-${grouped.join('-')}'; +} + +/// The share code in [value], which is a share link (see [isIcarusShareUri]) +/// or a bare code. A link to any other site gives null. +String? extractIcarusShareCode(String value, {required Uri currentOrigin}) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return null; + } + + final uri = Uri.tryParse(trimmed); + if (uri != null) { + if (isIcarusShareUri(uri, currentOrigin: currentOrigin)) { + try { + final token = + uri.queryParameters['token'] ?? uri.queryParameters['code']; + if (token != null && token.isNotEmpty) { + return _normalizeCodeOrLegacyToken(token); + } + + final shareIndex = uri.pathSegments.indexWhere( + (segment) => segment.toLowerCase() == 'share', + ); + if (shareIndex >= 0 && shareIndex + 1 < uri.pathSegments.length) { + return _normalizeCodeOrLegacyToken(uri.pathSegments[shareIndex + 1]); + } + } on FormatException { + return null; // A query that does not decode holds no usable code. + } + } + + if (uri.hasScheme && (uri.host.isNotEmpty || uri.scheme == 'icarus')) { + return null; + } + } + + return _normalizeCodeOrLegacyToken(trimmed); +} + +String _normalizeCodeOrLegacyToken(String value) { + final trimmed = value.trim(); + final upper = trimmed.toUpperCase(); + if (_shareCodePattern.hasMatch(upper)) { + return upper; + } + return trimmed; +} diff --git a/lib/startup/cloud_bootstrap.dart b/lib/startup/cloud_bootstrap.dart new file mode 100644 index 00000000..2e3a48b2 --- /dev/null +++ b/lib/startup/cloud_bootstrap.dart @@ -0,0 +1,84 @@ +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/config/cloud_build_config.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/local_image_file.dart' show deviceHasImageFiles; +import 'package:supabase_flutter/supabase_flutter.dart'; + +const cloudOutboxUnavailableReason = + "Unsent cloud changes on this device couldn't be opened, so cloud sync " + 'is off for now. They are kept as they are. Your library on this device ' + 'works as usual.'; + +const cloudClientsUnavailableReason = + "Cloud sync couldn't start, so sign-in is unavailable. Your library on " + 'this device works as usual. Restart Icarus to try again.'; + +/// Brings up cloud sync: first the outboxes holding unsent cloud work, then +/// the Convex and Supabase clients. It never throws. The first step that +/// fails leaves the cloud unavailable for this run and the later steps unrun, +/// so a failure here can never keep anyone from the library on this device. +Future startCloud({ + required Future Function() openOutboxes, + required Future Function() initializeClients, +}) async { + try { + await openOutboxes(); + } catch (error, stackTrace) { + return _unavailable(cloudOutboxUnavailableReason, error, stackTrace); + } + try { + await initializeClients(); + } catch (error, stackTrace) { + return _unavailable(cloudClientsUnavailableReason, error, stackTrace); + } + return CloudStartup.ready; +} + +CloudStartup _unavailable(String reason, Object error, StackTrace stackTrace) { + AppErrorReporter.reportError( + reason, + error: error, + stackTrace: stackTrace, + source: 'startCloud', + // Nothing is on screen yet; the app tells the user once it is. + promptUser: false, + ); + return CloudStartup.unavailable(reason); +} + +/// Opens the boxes that hold unsent cloud work. Hive's crash recovery stays +/// on: a crash in the middle of a write leaves a torn last frame, and +/// recovery trims only that write, which never completed and was never +/// reported saved. Turning it off would leave cloud sync off after any such +/// crash, with no way back. +Future openCloudOutboxes() async { + await Hive.openBox(HiveBoxNames.strategyOutboxBox); + await prepareDurableStrategyOutbox(); + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + await prepareDurableCloudMediaOutbox(); + if (!deviceHasImageFiles) { + await Hive.openBox(HiveBoxNames.pendingMediaBytesBox); + } +} + +Future initializeCloudClients(CloudBuildConfig config) async { + await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: config.deploymentUrl, + clientId: config.clientId, + operationTimeout: const Duration(seconds: 30), + healthCheckQuery: defaultConvexHealthCheckQuery, + ), + ); + + await Supabase.initialize( + url: 'https://gjdirtrtgnawqoruavqn.supabase.co', + anonKey: 'sb_publishable_6M0VCSZCvRFrcgNANWPVWw_U06T_rUo', + authOptions: const FlutterAuthClientOptions(detectSessionInUri: false), + ); +} diff --git a/lib/strategy/lineup_group_changes.dart b/lib/strategy/lineup_group_changes.dart new file mode 100644 index 00000000..12f8ddc8 --- /dev/null +++ b/lib/strategy/lineup_group_changes.dart @@ -0,0 +1,136 @@ +import 'dart:convert'; + +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:uuid/uuid.dart'; + +/// What happened to one lineup between two versions of its group. +class LineupChange { + const LineupChange({required this.label, required this.description}); + + /// The lineup as the user knows it: its name, or its agent's. + final String label; + + /// What changed, e.g. "added" or "notes edited, landing moved". + final String description; +} + +/// The lineups that differ between [from] and [to], one entry each, in +/// [to]'s order and then the lineups only [from] has. +List lineupChanges({ + required LineUpGraph from, + required LineUpGraph to, +}) { + final before = _Lookup(from); + final after = _Lookup(to); + final changes = []; + for (final link in to.links) { + final old = before.links[link.id]; + if (old == null) { + changes.add(LineupChange(label: after.label(link), description: 'added')); + continue; + } + final what = [ + if (old.name != link.name) 'renamed', + if (old.notes != link.notes) 'notes edited', + if (old.youtubeLink != link.youtubeLink) 'video changed', + if (!_sameJson( + [for (final image in old.images) image.toJson()], + [for (final image in link.images) image.toJson()], + )) + 'images changed', + if (!_sameJson( + before.origins[old.originId]?.toJson(), + after.origins[link.originId]?.toJson(), + )) + 'standing spot moved', + if (!_sameJson( + before.landings[old.landingId]?.toJson(), + after.landings[link.landingId]?.toJson(), + )) + 'landing moved', + ]; + if (what.isNotEmpty) { + changes.add( + LineupChange(label: after.label(link), description: what.join(', ')), + ); + } + } + for (final link in from.links) { + if (!after.links.containsKey(link.id)) { + changes.add( + LineupChange(label: before.label(link), description: 'deleted'), + ); + } + } + return changes; +} + +/// A copy of [graph] under fresh ids, for keeping it beside the version it +/// conflicted with: its lineups, spots, and the agent and ability on each +/// spot are all new, so nothing in it is the original. Images stay the same +/// images. +LineUpGraph forkLineUpGraph(LineUpGraph graph) { + const uuid = Uuid(); + final originIds = {for (final origin in graph.origins) origin.id: uuid.v4()}; + final landingIds = { + for (final landing in graph.landings) landing.id: uuid.v4(), + }; + return LineUpGraph( + origins: [ + for (final origin in graph.origins) + LineUpOrigin( + id: originIds[origin.id]!, + agent: origin.agent.copyWith( + id: uuid.v4(), + lineUpID: originIds[origin.id], + )..isDeleted = origin.agent.isDeleted, + ), + ], + landings: [ + for (final landing in graph.landings) + LineUpLanding( + id: landingIds[landing.id]!, + ability: landing.ability.copyWith( + id: uuid.v4(), + lineUpID: landingIds[landing.id], + )..isDeleted = landing.ability.isDeleted, + ), + ], + links: [ + for (final link in graph.links) + if ((originIds[link.originId], landingIds[link.landingId]) + case (final originId?, final landingId?)) + link.copyWith( + id: uuid.v4(), + originId: originId, + landingId: landingId, + ), + ], + ); +} + +/// Model `toJson()` output can nest objects with their own `toJson`, so it +/// goes through a plain JSON round trip before the canonical comparison. +bool _sameJson(Object? a, Object? b) => + canonicalCloudJsonEncode(jsonDecode(jsonEncode(a))) == + canonicalCloudJsonEncode(jsonDecode(jsonEncode(b))); + +class _Lookup { + _Lookup(LineUpGraph graph) + : origins = {for (final origin in graph.origins) origin.id: origin}, + landings = {for (final landing in graph.landings) landing.id: landing}, + links = {for (final link in graph.links) link.id: link}; + + final Map origins; + final Map landings; + final Map links; + + String label(LineUpLink link) { + final name = link.name.trim(); + if (name.isNotEmpty) return name; + final agent = AgentData.agents[origins[link.originId]?.agent.type]?.name; + return agent == null ? 'Unnamed lineup' : '$agent lineup'; + } +} diff --git a/lib/strategy/remote_page_merge.dart b/lib/strategy/remote_page_merge.dart new file mode 100644 index 00000000..c11d1b67 --- /dev/null +++ b/lib/strategy/remote_page_merge.dart @@ -0,0 +1,119 @@ +import 'dart:math' show min; + +import 'package:icarus/const/line_provider.dart'; + +/// The server's copy of a page's items ([incoming]), except the ones [keep] +/// names: those stay the object on screen, so the widget keeps whatever +/// in-progress state it holds, and stay even when the server removed them. +/// +/// Items keep the server's order. A kept item the server removed keeps its +/// place on screen, so live sync, which reads order from list position, sees +/// no move the user never made. +List mergeRemoteItems({ + required List current, + required List incoming, + required String Function(T item) idOf, + required bool Function(String id) keep, +}) { + final currentById = {for (final item in current) idOf(item): item}; + final incomingIds = {for (final item in incoming) idOf(item)}; + final merged = [ + for (final item in incoming) + if (currentById[idOf(item)] case final onScreen? when keep(idOf(item))) + onScreen + else + item, + ]; + for (final (index, item) in current.indexed) { + if (!incomingIds.contains(idOf(item)) && keep(idOf(item))) { + merged.insert(min(index, merged.length), item); + } + } + return merged; +} + +/// The server's lineups ([remote]), except the ones the user is holding: +/// those stay as the canvas ([local]) has them, as [mergeRemoteItems] keeps +/// held items. Returns the merged graph and the lineup groups held. +/// +/// The user holds a lineup group by holding one of its lineups or spots; +/// [groupOf] names the group of a lineup or spot, local or remote. A group +/// is one cloud row, so it takes one side whole: every lineup in a held +/// group, and every lineup sharing a spot with one, stays as the canvas has +/// it. The other lineups take the server's copy. A held lineup only the +/// server has (a teammate's new lineup in a held group) waits off the +/// canvas until the hold ends, like a held lineup the server removed waits +/// on it. +(LineUpGraph, Set) mergeHeldLineups({ + required LineUpGraph local, + required LineUpGraph remote, + required Set holding, + required String? Function(String id) groupOf, +}) { + final allLinks = [...local.links, ...remote.links]; + final held = {}; + final heldIds = {...holding}; + final heldGroups = { + for (final id in holding) + if (groupOf(id) case final group?) group, + }; + for (var grew = true; grew;) { + grew = false; + for (final link in allLinks) { + if (held.contains(link.id)) continue; + final group = groupOf(link.id); + if (heldIds.contains(link.id) || + heldIds.contains(link.originId) || + heldIds.contains(link.landingId) || + heldGroups.contains(group)) { + held.add(link.id); + heldIds.addAll([link.originId, link.landingId]); + if (group != null) heldGroups.add(group); + grew = true; + } + } + } + if (held.isEmpty) return (remote, heldGroups); + + final localLinks = {for (final link in local.links) link.id: link}; + final remoteLinkIds = {for (final link in remote.links) link.id}; + final links = [ + for (final link in remote.links) + if (!held.contains(link.id)) + link + else if (localLinks[link.id] case final onScreen?) + onScreen, + ]; + for (final (index, link) in local.links.indexed) { + if (held.contains(link.id) && !remoteLinkIds.contains(link.id)) { + links.insert(min(index, links.length), link); + } + } + + final originIds = {for (final link in links) link.originId}; + final landingIds = {for (final link in links) link.landingId}; + return ( + LineUpGraph( + origins: [ + for (final origin in mergeRemoteItems( + current: local.origins, + incoming: remote.origins, + idOf: (origin) => origin.id, + keep: heldIds.contains, + )) + if (originIds.contains(origin.id)) origin, + ], + landings: [ + for (final landing in mergeRemoteItems( + current: local.landings, + incoming: remote.landings, + idOf: (landing) => landing.id, + keep: heldIds.contains, + )) + if (landingIds.contains(landing.id)) landing, + ], + links: links, + ), + heldGroups, + ); +} diff --git a/lib/strategy/strategy_cloud_migration.dart b/lib/strategy/strategy_cloud_migration.dart new file mode 100644 index 00000000..e225702e --- /dev/null +++ b/lib/strategy/strategy_cloud_migration.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:uuid/uuid.dart'; + +void appendMigratedPageOps( + List ops, + StrategyPage page, { + required Set usedElementIds, + required Set usedLineupIds, +}) { + var elementOrder = 0; + + for (final agent in page.agentData) { + final elementId = nextUniqueMigrationId(agent.id, usedElementIds); + final payload = Map.from(agent.toJson()) + ..putIfAbsent('elementType', () => 'agent') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final ability in page.abilityData) { + final elementId = nextUniqueMigrationId(ability.id, usedElementIds); + final payload = Map.from(ability.toJson()) + ..putIfAbsent('elementType', () => 'ability') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final drawing in page.drawingData) { + final elementId = nextUniqueMigrationId(drawing.id, usedElementIds); + final encodedList = + jsonDecode(DrawingProvider.objectToJson([drawing])) as List; + final payload = Map.from( + (encodedList.isNotEmpty ? encodedList.first : {}) as Map, + ) + ..putIfAbsent('elementType', () => 'drawing') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final text in page.textData) { + final elementId = nextUniqueMigrationId(text.id, usedElementIds); + final payload = Map.from(text.toJson()) + ..putIfAbsent('elementType', () => 'text') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final image in page.imageData) { + final elementId = nextUniqueMigrationId(image.id, usedElementIds); + final payload = cloudImagePayloadFromPlacedImage(image) + ..putIfAbsent('elementType', () => 'image') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + for (final utility in page.utilityData) { + final elementId = nextUniqueMigrationId(utility.id, usedElementIds); + final payload = Map.from(utility.toJson()) + ..putIfAbsent('elementType', () => 'utility') + ..['id'] = elementId; + ops.add( + buildMigratedElementOp(page.id, elementId, payload, elementOrder++)); + } + + // One row per lineup group. A group's id must be unique in the strategy, + // so one whose id another page already took (a page duplicated before + // cloud sync repeats its lineup ids) takes another of its lineup ids. + // Ids inside a row only need to be unique in the row, so they stay. + final rows = cloudLineupRows( + page.lineUpGraph, + takenGroupIds: usedLineupIds, + ).rows; + usedLineupIds.addAll([for (final row in rows) row.publicId]); + var lineupOrder = 0; + for (final row in rows) { + ops.add( + LineupAddOp( + opId: const Uuid().v4(), + lineupPublicId: row.publicId, + pagePublicId: page.id, + payload: row.payload, + sortIndex: lineupOrder++, + ), + ); + } +} + +String nextUniqueMigrationId(String preferredId, Set usedIds) { + if (usedIds.add(preferredId)) { + return preferredId; + } + + var generated = const Uuid().v4(); + while (!usedIds.add(generated)) { + generated = const Uuid().v4(); + } + return generated; +} + +StrategyOp buildMigratedElementOp( + String pagePublicId, + String elementId, + Map payload, + int sortIndex, +) { + return ElementAddOp( + opId: const Uuid().v4(), + elementPublicId: elementId, + pagePublicId: pagePublicId, + payload: cloudElementPayload( + kind: payload['elementType'] as String? ?? 'generic', + data: payload, + ), + sortIndex: sortIndex, + ); +} diff --git a/lib/strategy/strategy_import_export.dart b/lib/strategy/strategy_import_export.dart new file mode 100644 index 00000000..ae49dd8f --- /dev/null +++ b/lib/strategy/strategy_import_export.dart @@ -0,0 +1,2586 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/favorite_agents_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/archive_manifest.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; + +String sanitizeStrategyFileName(String input) { + final sanitized = input.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); + return sanitized.isEmpty ? 'untitled' : sanitized; +} + +String buildLibraryBackupFileName(DateTime timestamp) { + String twoDigit(int value) => value.toString().padLeft(2, '0'); + return 'icarus-library-backup-' + '${timestamp.year}-${twoDigit(timestamp.month)}-${twoDigit(timestamp.day)}_' + '${twoDigit(timestamp.hour)}-${twoDigit(timestamp.minute)}-${twoDigit(timestamp.second)}.zip'; +} + +typedef CloudStrategyExporter = Future Function(String strategyId); + +final cloudStrategyExporterProvider = Provider( + (ref) => StrategyImportExportService(ref).exportCloudStrategy, +); + +class NewerVersionImportException implements Exception { + const NewerVersionImportException({ + required this.importedVersion, + required this.currentVersion, + }); + + final int importedVersion; + final int currentVersion; + + static const String userMessage = + 'This strategy was created in a newer version of Icarus. ' + 'Please update the app and try again.'; + + @override + String toString() { + return 'NewerVersionImportException(' + 'importedVersion: $importedVersion, ' + 'currentVersion: $currentVersion' + ')'; + } +} + +enum ImportIssueCode { + newerVersion, + invalidStrategy, + invalidArchiveMetadata, + unsupportedFile, + ioError, +} + +class ImportIssue { + const ImportIssue({ + required this.path, + required this.code, + }); + + final String path; + final ImportIssueCode code; +} + +class ImportBatchResult { + const ImportBatchResult({ + required this.strategiesImported, + required this.foldersCreated, + this.themeProfilesImported = 0, + this.globalStateRestored = false, + required this.issues, + }); + + const ImportBatchResult.empty() + : strategiesImported = 0, + foldersCreated = 0, + themeProfilesImported = 0, + globalStateRestored = false, + issues = const []; + + final int strategiesImported; + final int foldersCreated; + final int themeProfilesImported; + final bool globalStateRestored; + final List issues; + + bool get hasImports => + strategiesImported > 0 || + foldersCreated > 0 || + themeProfilesImported > 0 || + globalStateRestored; + + ImportBatchResult merge(ImportBatchResult other) { + return ImportBatchResult( + strategiesImported: strategiesImported + other.strategiesImported, + foldersCreated: foldersCreated + other.foldersCreated, + themeProfilesImported: + themeProfilesImported + other.themeProfilesImported, + globalStateRestored: globalStateRestored || other.globalStateRestored, + issues: [...issues, ...other.issues], + ); + } +} + +class _ImportEntityListing { + const _ImportEntityListing({ + required this.entities, + required this.issues, + }); + + final List entities; + final List issues; +} + +class _ArchiveExportState { + _ArchiveExportState({ + required this.rootDirectory, + }); + + final Directory rootDirectory; + final List folders = []; + final List strategies = []; +} + +class _ManifestImportData { + const _ManifestImportData({ + required this.rootDirectory, + required this.manifestFile, + required this.manifest, + }); + + final Directory rootDirectory; + final File manifestFile; + final ArchiveManifest manifest; +} + +class _GlobalImportResult { + const _GlobalImportResult({ + required this.themeProfilesImported, + required this.globalStateRestored, + required this.profileIdRemap, + }); + + final int themeProfilesImported; + final bool globalStateRestored; + final Map profileIdRemap; +} + +class _ZipManifestData { + const _ZipManifestData({ + required this.manifest, + required this.rootPrefix, + required this.filesByPath, + required this.manifestArchivePath, + }); + + final ArchiveManifest manifest; + final String rootPrefix; + final Map filesByPath; + final String manifestArchivePath; +} + +class StrategyImportExportService { + StrategyImportExportService(this.ref); + + final dynamic ref; + + void _reportImportFailure( + String message, { + Object? error, + StackTrace? stackTrace, + required String source, + }) { + AppErrorReporter.reportError( + message, + error: error, + stackTrace: stackTrace, + source: source, + promptUser: false, + ); + } + + Future loadFromFilePath(String filePath) async { + await _importStrategyFile( + file: XFile(filePath), + targetFolderId: null, + ); + } + + Future loadFromFilePicker() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.custom, + allowedExtensions: ['ica'], + ); + + if (result == null) return; + + for (final file in result.files) { + await _importStrategyFile( + file: file.xFile, + targetFolderId: null, + ); + } + } + + Future importBackupFromFilePicker() async { + final result = await FilePicker.platform.pickFiles( + allowMultiple: false, + type: FileType.custom, + allowedExtensions: ['zip'], + ); + + if (result == null || result.files.isEmpty) { + return const ImportBatchResult.empty(); + } + + final pickedFile = result.files.single; + final filePath = pickedFile.path; + if (filePath == null || filePath.isEmpty) { + return const ImportBatchResult.empty(); + } + + return _importZipArchive( + zipFile: File(filePath), + parentFolderId: null, + ); + } + + Future loadFromFileDrop(List files) async { + final targetFolderId = ref.read(folderProvider); + var result = const ImportBatchResult.empty(); + + for (final file in files) { + result = result.merge( + await _importDroppedItem( + file: file, + targetFolderId: targetFolderId, + ), + ); + } + + return result; + } + + Future getTempDirectory(String strategyID) async { + String tempDirectoryPath; + try { + tempDirectoryPath = (await getTemporaryDirectory()).path; + } on MissingPluginException { + tempDirectoryPath = Directory.systemTemp.path; + } on MissingPlatformDirectoryException { + tempDirectoryPath = Directory.systemTemp.path; + } + + return Directory( + path.join(tempDirectoryPath, 'xyz_icarus_strats', strategyID), + ).create(recursive: true); + } + + Future cleanUpTempDirectory(String strategyID) async { + final tempDirectory = await getTempDirectory(strategyID); + await tempDirectory.delete(recursive: true); + } + + Future _getApplicationSupportDirectoryOrSystemTemp() async { + try { + return await getApplicationSupportDirectory(); + } on MissingPluginException { + return Directory.systemTemp; + } on MissingPlatformDirectoryException { + return Directory.systemTemp; + } + } + + Future _extractArchiveEntriesToDisk({ + required Archive archive, + required Directory destination, + }) async { + final destinationPath = path.normalize(destination.path); + + for (final entry in archive) { + final normalizedName = normalizeArchivePath(entry.name); + if (normalizedName.isEmpty) { + continue; + } + if (path.isAbsolute(normalizedName)) { + continue; + } + + final segments = path.posix.split(normalizedName); + if (segments.any((segment) => segment == '..')) { + continue; + } + + final targetPath = path.joinAll([ + destinationPath, + ...segments, + ]); + final normalizedTargetPath = path.normalize(targetPath); + final isWithinDestination = + path.isWithin(destinationPath, normalizedTargetPath) || + normalizedTargetPath == destinationPath; + if (!isWithinDestination) { + continue; + } + + if (entry.isFile) { + final targetFile = File(normalizedTargetPath); + await targetFile.parent.create(recursive: true); + await targetFile.writeAsBytes(entry.content as List); + } else { + await Directory(normalizedTargetPath).create(recursive: true); + } + } + } + + Future isZipFile(File file) async { + final raf = file.openSync(mode: FileMode.read); + final header = raf.readSync(4); + await raf.close(); + + return header.length == 4 && + header[0] == 0x50 && + header[1] == 0x4B && + header[2] == 0x03 && + header[3] == 0x04; + } + + Future _importDroppedItem({ + required XFile file, + required String? targetFolderId, + }) async { + if (file.path.isEmpty) { + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue(path: '', code: ImportIssueCode.ioError), + ], + ); + } + + try { + final entityType = + await FileSystemEntity.type(file.path, followLinks: false); + switch (entityType) { + case FileSystemEntityType.directory: + return await _importDirectoryTree( + sourceDir: Directory(file.path), + parentFolderId: targetFolderId, + ); + case FileSystemEntityType.file: + final extension = path.extension(file.path).toLowerCase(); + if (extension == '.ica') { + await _importStrategyFile( + file: file, + targetFolderId: targetFolderId, + ); + return const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ); + } + + if (await isZipFile(File(file.path))) { + return await _importZipArchive( + zipFile: File(file.path), + parentFolderId: targetFolderId, + ); + } + + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.unsupportedFile, + ), + ], + ); + case FileSystemEntityType.notFound: + case FileSystemEntityType.link: + case FileSystemEntityType.unixDomainSock: + case FileSystemEntityType.pipe: + default: + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.ioError, + ), + ], + ); + } + } on NewerVersionImportException { + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.newerVersion, + ), + ], + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import dropped item ${file.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importDroppedItem', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: file.path, + code: ImportIssueCode.ioError, + ), + ], + ); + } + } + + Future _createImportedFolder({ + required String name, + required String? parentFolderId, + }) { + return ref.read(folderProvider.notifier).createFolder( + name: name, + iconId: FolderIconRegistry.idForLegacyIconData( + Icons.drive_folder_upload, + ), + color: FolderColor.generic, + parentID: parentFolderId, + ); + } + + List _sortedImportEntities( + Iterable entities, + ) { + final filtered = entities.where((entity) { + final basename = path.basename(entity.path); + return !_shouldIgnoreImportedEntityName(basename); + }).toList(); + filtered.sort((a, b) => a.path.compareTo(b.path)); + return filtered; + } + + bool _shouldIgnoreImportedEntityName(String name) { + return name.isEmpty || + name == '__MACOSX' || + name == '.DS_Store' || + name == archiveMetadataFileName || + name.startsWith('._'); + } + + bool _isIcaFileEntity(FileSystemEntity entity) { + return entity is File && + path.extension(entity.path).toLowerCase() == '.ica'; + } + + Future<_ImportEntityListing> _listImportEntities(Directory directory) async { + final issues = []; + try { + final entities = directory.listSync(followLinks: false); + return _ImportEntityListing( + entities: _sortedImportEntities(entities), + issues: issues, + ); + } on FileSystemException catch (error, stackTrace) { + final errorPath = _resolveImportErrorPath(error, directory.path); + _reportImportFailure( + 'Failed to list import directory $errorPath.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._listImportEntities', + ); + issues.add( + ImportIssue( + path: errorPath, + code: ImportIssueCode.ioError, + ), + ); + } + + return _ImportEntityListing( + entities: const [], + issues: issues, + ); + } + + String _resolveImportErrorPath(Object error, String fallbackPath) { + if (error is FileSystemException) { + return error.path ?? fallbackPath; + } + return fallbackPath; + } + + Future _importEntitiesIntoFolder({ + required Iterable entities, + required String parentFolderId, + }) async { + var result = const ImportBatchResult.empty(); + final sortedEntities = _sortedImportEntities(entities); + + for (final entity in sortedEntities) { + if (entity is Directory) { + result = result.merge( + await _importDirectoryTree( + sourceDir: entity, + parentFolderId: parentFolderId, + ), + ); + continue; + } + + if (_isIcaFileEntity(entity)) { + try { + await _importStrategyFile( + file: XFile(entity.path), + targetFolderId: parentFolderId, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import strategy file ${entity.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importEntitiesIntoFolder', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + continue; + } + + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ], + ), + ); + } + + return result; + } + + Future _importDirectoryTree({ + required Directory sourceDir, + required String? parentFolderId, + }) async { + final manifestFile = + File(path.join(sourceDir.path, archiveMetadataFileName)); + _ManifestImportData? manifestData; + if (await manifestFile.exists()) { + try { + manifestData = await _loadManifestIfPresent(sourceDir); + if (manifestData != null) { + _validateArchiveManifest(manifestData); + } + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest archive from ${sourceDir.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importDirectoryTree', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: manifestFile.path, + code: ImportIssueCode.invalidArchiveMetadata, + ), + ], + ).merge( + await _importDirectoryTreeLegacy( + sourceDir: sourceDir, + parentFolderId: parentFolderId, + ), + ); + } + } + + if (manifestData != null) { + return _importManifestArchive( + manifestData: manifestData, + parentFolderId: parentFolderId, + ); + } + + return _importDirectoryTreeLegacy( + sourceDir: sourceDir, + parentFolderId: parentFolderId, + ); + } + + Future _importDirectoryTreeLegacy({ + required Directory sourceDir, + required String? parentFolderId, + }) async { + final importedFolder = await _createImportedFolder( + name: path.basename(sourceDir.path), + parentFolderId: parentFolderId, + ); + + var result = const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ); + + final listing = await _listImportEntities(sourceDir); + if (listing.issues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: listing.issues, + ), + ); + } + + result = result.merge( + await _importEntitiesIntoFolder( + entities: listing.entities, + parentFolderId: importedFolder.id, + ), + ); + + return result; + } + + Future _importZipArchive({ + required File zipFile, + required String? parentFolderId, + }) async { + final archive = ZipDecoder().decodeBytes(await zipFile.readAsBytes()); + _ZipManifestData? manifestData; + try { + manifestData = _loadManifestFromArchive(archive); + if (manifestData != null) { + _validateArchiveManifestFromZip(manifestData); + } + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest zip ${zipFile.path}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importZipArchive', + ); + return ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: zipFile.path, + code: ImportIssueCode.invalidArchiveMetadata, + ), + ], + ).merge( + await _importLegacyZipArchiveFromEntries( + archive: archive, + parentFolderId: parentFolderId, + zipFileName: path.basenameWithoutExtension(zipFile.path), + ), + ); + } + + if (manifestData != null) { + return _importManifestArchiveFromZip( + manifestData: manifestData, + parentFolderId: parentFolderId, + ); + } + + return _importLegacyZipArchiveFromEntries( + archive: archive, + parentFolderId: parentFolderId, + zipFileName: path.basenameWithoutExtension(zipFile.path), + ); + } + + _ZipManifestData? _loadManifestFromArchive(Archive archive) { + final filesByPath = {}; + for (final entry in archive) { + if (!entry.isFile) { + continue; + } + filesByPath[normalizeArchivePath(entry.name)] = entry; + } + + final manifestPaths = filesByPath.keys + .where((pathValue) => + path.posix.basename(pathValue) == archiveMetadataFileName) + .toList(growable: false); + if (manifestPaths.isEmpty) { + return null; + } + if (manifestPaths.length > 1) { + throw const FormatException('Archive contains multiple manifest files'); + } + + final manifestArchivePath = manifestPaths.single; + final manifestEntry = filesByPath[manifestArchivePath]!; + final decoded = jsonDecode(utf8.decode(_archiveFileBytes(manifestEntry))); + if (decoded is! Map) { + throw const FormatException('Archive manifest must decode to an object'); + } + + final rootPrefix = path.posix.dirname(manifestArchivePath); + return _ZipManifestData( + manifest: ArchiveManifest.fromJson(decoded), + rootPrefix: rootPrefix == '.' ? '' : rootPrefix, + filesByPath: filesByPath, + manifestArchivePath: manifestArchivePath, + ); + } + + List _archiveFileBytes(ArchiveFile entry) { + return entry.content as List; + } + + Future _writeArchiveEntryToTempFile({ + required ArchiveFile archiveFile, + required Directory tempDirectory, + }) async { + final baseName = path.basename(normalizeArchivePath(archiveFile.name)); + final file = File(path.join(tempDirectory.path, baseName)); + await file.parent.create(recursive: true); + await file.writeAsBytes(_archiveFileBytes(archiveFile)); + return file; + } + + Future _importManifestArchiveFromZip({ + required _ZipManifestData manifestData, + required String? parentFolderId, + }) async { + var result = const ImportBatchResult.empty(); + var profileIdRemap = const {}; + + if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { + final globals = manifestData.manifest.globals; + if (globals == null) { + throw const FormatException( + 'Library backup archive is missing globals'); + } + final globalImportResult = await _importArchiveGlobals(globals); + profileIdRemap = globalImportResult.profileIdRemap; + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: globalImportResult.themeProfilesImported, + globalStateRestored: globalImportResult.globalStateRestored, + issues: const [], + ), + ); + } + + final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { + final depthCompare = _archivePathDepth(a.archivePath) + .compareTo(_archivePathDepth(b.archivePath)); + if (depthCompare != 0) { + return depthCompare; + } + return a.archivePath.compareTo(b.archivePath); + }); + + final localFolderIdsByManifestId = {}; + for (final folderEntry in folderEntries) { + final resolvedParentFolderId = folderEntry.parentManifestId == null + ? (manifestData.manifest.archiveType == ArchiveType.folderTree + ? parentFolderId + : null) + : localFolderIdsByManifestId[folderEntry.parentManifestId!]; + if (folderEntry.parentManifestId != null && + resolvedParentFolderId == null) { + throw FormatException( + 'Missing parent folder mapping for ${folderEntry.manifestId}', + ); + } + + final createdFolder = + await ref.read(folderProvider.notifier).createFolder( + name: folderEntry.name, + iconId: folderEntry.iconId, + color: folderEntry.color, + customColor: folderEntry.customColorValue == null + ? null + : Color(folderEntry.customColorValue!), + parentID: resolvedParentFolderId, + ); + localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; + result = result.merge( + const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } + + final materializedDirectory = + await Directory.systemTemp.createTemp('icarus-zip-manifest-import'); + try { + for (final strategyEntry in [...manifestData.manifest.strategies] + ..sort((a, b) => a.archivePath.compareTo(b.archivePath))) { + final targetFolderId = strategyEntry.folderManifestId == null + ? null + : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; + final archivePath = _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategyEntry.archivePath, + ); + final archiveFile = manifestData.filesByPath[archivePath]; + if (archiveFile == null) { + throw FormatException('Missing strategy file: $archivePath'); + } + + try { + final tempFile = await _writeArchiveEntryToTempFile( + archiveFile: archiveFile, + tempDirectory: materializedDirectory, + ); + await _importStrategyFile( + file: XFile(tempFile.path), + targetFolderId: targetFolderId, + displayNameOverride: strategyEntry.name, + themeProfileIdRemap: profileIdRemap, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest strategy $archivePath.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importManifestArchiveFromZip', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + } finally { + try { + await materializedDirectory.delete(recursive: true); + } catch (_) {} + } + + final undeclaredIssues = _collectUndeclaredZipArchiveIssues(manifestData); + if (undeclaredIssues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: undeclaredIssues, + ), + ); + } + + return result; + } + + void _validateArchiveManifestFromZip(_ZipManifestData manifestData) { + final manifest = manifestData.manifest; + final folderIds = {}; + final folderPaths = {}; + final rootFolders = []; + + for (final folder in manifest.folders) { + if (!folderIds.add(folder.manifestId)) { + throw FormatException( + 'Duplicate folder manifest ID: ${folder.manifestId}'); + } + if (!folderPaths.add(folder.archivePath)) { + throw FormatException( + 'Duplicate folder archive path: ${folder.archivePath}'); + } + if (folder.parentManifestId == null) { + rootFolders.add(folder); + } else if (!manifest.folders.any( + (candidate) => candidate.manifestId == folder.parentManifestId)) { + throw FormatException('Missing parent folder for ${folder.manifestId}'); + } + } + + if (manifest.archiveType == ArchiveType.folderTree) { + if (rootFolders.length != 1) { + throw const FormatException( + 'Folder tree archives must contain one root'); + } + if (rootFolders.single.archivePath.isNotEmpty) { + throw const FormatException( + 'Folder tree root folder must use the manifest root path', + ); + } + } + + final knownFolderIds = + manifest.folders.map((folder) => folder.manifestId).toSet(); + final strategyPaths = {}; + for (final strategy in manifest.strategies) { + if (!strategyPaths.add(strategy.archivePath)) { + throw FormatException( + 'Duplicate strategy archive path: ${strategy.archivePath}', + ); + } + if (strategy.folderManifestId != null && + !knownFolderIds.contains(strategy.folderManifestId)) { + throw FormatException( + 'Unknown strategy folder reference: ${strategy.folderManifestId}', + ); + } + if (manifest.archiveType == ArchiveType.folderTree && + strategy.folderManifestId == null) { + throw const FormatException( + 'Folder tree strategies must reference the exported root folder', + ); + } + final archivePath = _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategy.archivePath, + ); + if (!manifestData.filesByPath.containsKey(archivePath)) { + throw FormatException('Missing strategy file: $archivePath'); + } + } + } + + String _zipArchiveAbsolutePath({ + required String rootPrefix, + required String relativePath, + }) { + return normalizeArchivePath( + rootPrefix.isEmpty + ? relativePath + : path.posix.join(rootPrefix, relativePath), + ); + } + + List _collectUndeclaredZipArchiveIssues( + _ZipManifestData manifestData, + ) { + final allowedFiles = {manifestData.manifestArchivePath}; + for (final strategy in manifestData.manifest.strategies) { + allowedFiles.add( + _zipArchiveAbsolutePath( + rootPrefix: manifestData.rootPrefix, + relativePath: strategy.archivePath, + ), + ); + } + + final issues = []; + for (final archivePath in manifestData.filesByPath.keys) { + if (!allowedFiles.contains(archivePath) && + !_shouldIgnoreImportedEntityName(path.posix.basename(archivePath))) { + issues.add( + ImportIssue( + path: archivePath, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + } + return issues; + } + + Future _importLegacyZipArchiveFromEntries({ + required Archive archive, + required String? parentFolderId, + required String zipFileName, + }) async { + final filesByPath = {}; + for (final entry in archive) { + if (!entry.isFile) { + continue; + } + final normalizedPath = normalizeArchivePath(entry.name); + if (_shouldIgnoreImportedEntityName( + path.posix.basename(normalizedPath))) { + continue; + } + filesByPath[normalizedPath] = entry; + } + + final topLevelSegments = {}; + final looseTopLevelIca = []; + for (final archivePath in filesByPath.keys) { + final segments = archivePath.split('/'); + if (segments.isEmpty) { + continue; + } + topLevelSegments.add(segments.first); + if (segments.length == 1 && + path.extension(archivePath).toLowerCase() == '.ica') { + looseTopLevelIca.add(archivePath); + } + } + + if (topLevelSegments.length == 1 && looseTopLevelIca.isEmpty) { + return _importLegacyZipDirectory( + directoryPrefix: topLevelSegments.single, + filesByPath: filesByPath, + parentFolderId: parentFolderId, + ); + } + + final wrapperFolder = await _createImportedFolder( + name: zipFileName, + parentFolderId: parentFolderId, + ); + + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ).merge( + await _importLegacyZipEntitiesIntoFolder( + parentPrefix: '', + filesByPath: filesByPath, + parentFolderId: wrapperFolder.id, + ), + ); + } + + Future _importLegacyZipDirectory({ + required String directoryPrefix, + required Map filesByPath, + required String? parentFolderId, + }) async { + final importedFolder = await _createImportedFolder( + name: path.posix.basename(directoryPrefix), + parentFolderId: parentFolderId, + ); + + return const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ).merge( + await _importLegacyZipEntitiesIntoFolder( + parentPrefix: directoryPrefix, + filesByPath: filesByPath, + parentFolderId: importedFolder.id, + ), + ); + } + + Future _importLegacyZipEntitiesIntoFolder({ + required String parentPrefix, + required Map filesByPath, + required String parentFolderId, + }) async { + final directDirectories = {}; + final directFiles = []; + final normalizedParentPrefix = normalizeArchivePath(parentPrefix); + + for (final archivePath in filesByPath.keys) { + final parentPath = path.posix.dirname(archivePath); + if (normalizedParentPrefix.isEmpty) { + if (parentPath == '.') { + directFiles.add(archivePath); + } else if (!parentPath.contains('/')) { + directDirectories.add(parentPath); + } + continue; + } + + if (parentPath == normalizedParentPrefix) { + directFiles.add(archivePath); + continue; + } + + if (archivePath.startsWith('$normalizedParentPrefix/')) { + final remainder = + archivePath.substring(normalizedParentPrefix.length + 1); + if (remainder.isEmpty || !remainder.contains('/')) { + continue; + } + final childDirectory = remainder.substring(0, remainder.indexOf('/')); + directDirectories.add( + normalizeArchivePath( + path.posix.join(normalizedParentPrefix, childDirectory), + ), + ); + } + } + + var result = const ImportBatchResult.empty(); + + final tempDirectory = + await Directory.systemTemp.createTemp('icarus-zip-legacy-import'); + try { + final sortedDirectories = directDirectories.toList()..sort(); + for (final directoryPrefix in sortedDirectories) { + result = result.merge( + await _importLegacyZipDirectory( + directoryPrefix: directoryPrefix, + filesByPath: filesByPath, + parentFolderId: parentFolderId, + ), + ); + } + + directFiles.sort(); + for (final archivePath in directFiles) { + if (path.extension(archivePath).toLowerCase() != '.ica') { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.unsupportedFile, + ), + ], + ), + ); + continue; + } + + try { + final tempFile = await _writeArchiveEntryToTempFile( + archiveFile: filesByPath[archivePath]!, + tempDirectory: tempDirectory, + ); + await _importStrategyFile( + file: XFile(tempFile.path), + targetFolderId: parentFolderId, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import zip strategy $archivePath.', + error: error, + stackTrace: stackTrace, + source: + 'StrategyImportExportService._importLegacyZipEntitiesIntoFolder', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + } finally { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + + return result; + } + + Future<_ManifestImportData?> _loadManifestIfPresent( + Directory directory, + ) async { + final manifestFile = + File(path.join(directory.path, archiveMetadataFileName)); + if (!await manifestFile.exists()) { + return null; + } + + final raw = await manifestFile.readAsString(); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + throw const FormatException('Archive metadata must decode to an object'); + } + + return _ManifestImportData( + rootDirectory: directory, + manifestFile: manifestFile, + manifest: ArchiveManifest.fromJson(decoded), + ); + } + + Future _importManifestArchive({ + required _ManifestImportData manifestData, + required String? parentFolderId, + }) async { + _validateArchiveManifest(manifestData); + + var result = const ImportBatchResult.empty(); + var profileIdRemap = const {}; + + if (manifestData.manifest.archiveType == ArchiveType.libraryBackup) { + final globals = manifestData.manifest.globals; + if (globals == null) { + throw const FormatException( + 'Library backup archive is missing globals'); + } + final globalImportResult = await _importArchiveGlobals(globals); + profileIdRemap = globalImportResult.profileIdRemap; + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: globalImportResult.themeProfilesImported, + globalStateRestored: globalImportResult.globalStateRestored, + issues: const [], + ), + ); + } + + final folderEntries = [...manifestData.manifest.folders]..sort((a, b) { + final depthCompare = _archivePathDepth(a.archivePath) + .compareTo(_archivePathDepth(b.archivePath)); + if (depthCompare != 0) { + return depthCompare; + } + return a.archivePath.compareTo(b.archivePath); + }); + + final localFolderIdsByManifestId = {}; + for (final folderEntry in folderEntries) { + final resolvedParentFolderId = folderEntry.parentManifestId == null + ? (manifestData.manifest.archiveType == ArchiveType.folderTree + ? parentFolderId + : null) + : localFolderIdsByManifestId[folderEntry.parentManifestId!]; + if (folderEntry.parentManifestId != null && + resolvedParentFolderId == null) { + throw FormatException( + 'Missing parent folder mapping for ${folderEntry.manifestId}', + ); + } + + final createdFolder = + await ref.read(folderProvider.notifier).createFolder( + name: folderEntry.name, + iconId: folderEntry.iconId, + color: folderEntry.color, + customColor: folderEntry.customColorValue == null + ? null + : Color(folderEntry.customColorValue!), + parentID: resolvedParentFolderId, + ); + localFolderIdsByManifestId[folderEntry.manifestId] = createdFolder.id; + result = result.merge( + const ImportBatchResult( + strategiesImported: 0, + foldersCreated: 1, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } + + final strategyEntries = [...manifestData.manifest.strategies] + ..sort((a, b) => a.archivePath.compareTo(b.archivePath)); + for (final strategyEntry in strategyEntries) { + final targetFolderId = strategyEntry.folderManifestId == null + ? null + : localFolderIdsByManifestId[strategyEntry.folderManifestId!]; + if (strategyEntry.folderManifestId != null && targetFolderId == null) { + throw FormatException( + 'Missing folder mapping for strategy ${strategyEntry.archivePath}', + ); + } + + try { + await _importStrategyFile( + file: XFile( + _archivePathToFile( + manifestData.rootDirectory, + strategyEntry.archivePath, + ).path, + ), + targetFolderId: targetFolderId, + displayNameOverride: strategyEntry.name, + themeProfileIdRemap: profileIdRemap, + ); + result = result.merge( + const ImportBatchResult( + strategiesImported: 1, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [], + ), + ); + } on NewerVersionImportException { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: strategyEntry.archivePath, + code: ImportIssueCode.newerVersion, + ), + ], + ), + ); + } catch (error, stackTrace) { + _reportImportFailure( + 'Failed to import manifest strategy ${strategyEntry.archivePath}.', + error: error, + stackTrace: stackTrace, + source: 'StrategyImportExportService._importManifestArchive', + ); + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: [ + ImportIssue( + path: strategyEntry.archivePath, + code: ImportIssueCode.invalidStrategy, + ), + ], + ), + ); + } + } + + final undeclaredIssues = await _collectUndeclaredArchiveIssues( + manifestData: manifestData, + ); + if (undeclaredIssues.isNotEmpty) { + result = result.merge( + ImportBatchResult( + strategiesImported: 0, + foldersCreated: 0, + themeProfilesImported: 0, + globalStateRestored: false, + issues: undeclaredIssues, + ), + ); + } + + return result; + } + + void _validateArchiveManifest(_ManifestImportData manifestData) { + final manifest = manifestData.manifest; + final folderIds = {}; + final folderPaths = {}; + final rootFolders = []; + + for (final folder in manifest.folders) { + if (!folderIds.add(folder.manifestId)) { + throw FormatException( + 'Duplicate folder manifest ID: ${folder.manifestId}'); + } + if (!folderPaths.add(folder.archivePath)) { + throw FormatException( + 'Duplicate folder archive path: ${folder.archivePath}'); + } + if (folder.parentManifestId == null) { + rootFolders.add(folder); + } else if (!manifest.folders.any( + (candidate) => candidate.manifestId == folder.parentManifestId)) { + throw FormatException('Missing parent folder for ${folder.manifestId}'); + } + } + + if (manifest.archiveType == ArchiveType.folderTree) { + if (rootFolders.length != 1) { + throw const FormatException( + 'Folder tree archives must contain one root'); + } + if (rootFolders.single.archivePath.isNotEmpty) { + throw const FormatException( + 'Folder tree root folder must use the manifest root path', + ); + } + } + + final knownFolderIds = + manifest.folders.map((folder) => folder.manifestId).toSet(); + final strategyPaths = {}; + for (final strategy in manifest.strategies) { + if (!strategyPaths.add(strategy.archivePath)) { + throw FormatException( + 'Duplicate strategy archive path: ${strategy.archivePath}', + ); + } + if (strategy.folderManifestId != null && + !knownFolderIds.contains(strategy.folderManifestId)) { + throw FormatException( + 'Unknown strategy folder reference: ${strategy.folderManifestId}', + ); + } + if (manifest.archiveType == ArchiveType.folderTree && + strategy.folderManifestId == null) { + throw const FormatException( + 'Folder tree strategies must reference the exported root folder', + ); + } + if (!_archivePathToFile(manifestData.rootDirectory, strategy.archivePath) + .existsSync()) { + throw FormatException('Missing strategy file: ${strategy.archivePath}'); + } + } + } + + int _archivePathDepth(String archivePath) { + if (archivePath.isEmpty) { + return 0; + } + return archivePath.split('/').length; + } + + File _archivePathToFile(Directory rootDirectory, String archivePath) { + final normalized = normalizeArchivePath(archivePath); + final segments = + normalized.isEmpty ? const [] : normalized.split('/'); + return File(path.joinAll([rootDirectory.path, ...segments])); + } + + Future> _collectUndeclaredArchiveIssues({ + required _ManifestImportData manifestData, + }) async { + final allowedFiles = {archiveMetadataFileName}; + final allowedDirectories = {}; + + void addAllowedDirectoryAncestors(String archivePath) { + var current = normalizeArchivePath(archivePath); + if (current.isEmpty) { + return; + } + while (current.isNotEmpty && current != '.') { + allowedDirectories.add(current); + final parent = path.posix.dirname(current); + if (parent == '.' || parent == current) { + break; + } + current = parent; + } + } + + for (final folder in manifestData.manifest.folders) { + addAllowedDirectoryAncestors(folder.archivePath); + } + for (final strategy in manifestData.manifest.strategies) { + final normalizedPath = normalizeArchivePath(strategy.archivePath); + allowedFiles.add(normalizedPath); + final parentDirectory = path.posix.dirname(normalizedPath); + if (parentDirectory != '.') { + addAllowedDirectoryAncestors(parentDirectory); + } + } + + final issues = []; + await for (final entity in manifestData.rootDirectory + .list(recursive: true, followLinks: false)) { + final relativePath = normalizeArchivePath( + path.relative(entity.path, from: manifestData.rootDirectory.path), + ); + if (relativePath.isEmpty) { + continue; + } + + if (entity is File) { + if (!allowedFiles.contains(relativePath)) { + issues.add( + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + continue; + } + + final directoryAllowed = allowedDirectories.contains(relativePath) || + allowedFiles.any((allowed) => allowed.startsWith('$relativePath/')); + if (!directoryAllowed) { + issues.add( + ImportIssue( + path: entity.path, + code: ImportIssueCode.unsupportedFile, + ), + ); + } + } + + return issues; + } + + Future<_GlobalImportResult> _importArchiveGlobals( + ArchiveGlobals globals, + ) async { + await MapThemeProfilesProvider.bootstrap(); + + final profileBox = + Hive.box(HiveBoxNames.mapThemeProfilesBox); + final appPreferencesBox = + Hive.box(HiveBoxNames.appPreferencesBox); + final favoriteAgentsBox = Hive.box(HiveBoxNames.favoriteAgentsBox); + + final profileIdRemap = {}; + var themeProfilesImported = 0; + + final existingProfiles = profileBox.values.toList(); + for (final importedProfile in globals.themeProfiles) { + if (importedProfile.isBuiltIn) { + if (profileBox.get(importedProfile.id) != null) { + profileIdRemap[importedProfile.id] = importedProfile.id; + } + continue; + } + + final matchingExisting = existingProfiles.firstWhere( + (existing) => + !existing.isBuiltIn && + existing.name == importedProfile.name && + existing.palette == importedProfile.palette, + orElse: () => MapThemeProfile( + id: '', + name: '', + palette: MapThemeProfilesProvider.immutableDefaultPalette, + isBuiltIn: false, + ), + ); + + if (matchingExisting.id.isNotEmpty) { + profileIdRemap[importedProfile.id] = matchingExisting.id; + continue; + } + + var localProfileId = importedProfile.id; + if (profileBox.get(localProfileId) != null || + MapThemeProfilesProvider.immutableBuiltInProfiles + .any((profile) => profile.id == localProfileId)) { + localProfileId = const Uuid().v4(); + } + + final createdProfile = MapThemeProfile( + id: localProfileId, + name: importedProfile.name, + palette: importedProfile.palette, + isBuiltIn: false, + ); + await profileBox.put(createdProfile.id, createdProfile); + existingProfiles.add(createdProfile); + profileIdRemap[importedProfile.id] = createdProfile.id; + themeProfilesImported++; + } + + final resolvedDefaultProfileId = globals + .defaultThemeProfileIdForNewStrategies == + null + ? MapThemeProfilesProvider.immutableDefaultProfileId + : profileIdRemap[globals.defaultThemeProfileIdForNewStrategies!] ?? + (profileBox.get(globals.defaultThemeProfileIdForNewStrategies!) != + null + ? globals.defaultThemeProfileIdForNewStrategies! + : MapThemeProfilesProvider.immutableDefaultProfileId); + + final currentPreferences = appPreferencesBox + .get(MapThemeProfilesProvider.appPreferencesSingletonKey) ?? + AppPreferences( + defaultThemeProfileIdForNewStrategies: + MapThemeProfilesProvider.immutableDefaultProfileId, + ); + await appPreferencesBox.put( + MapThemeProfilesProvider.appPreferencesSingletonKey, + currentPreferences.copyWith( + defaultThemeProfileIdForNewStrategies: resolvedDefaultProfileId, + showSpawnBarrier: + globals.showSpawnBarrier ?? currentPreferences.showSpawnBarrier, + showUltOrbs: globals.showUltOrbs ?? currentPreferences.showUltOrbs, + showRegionNames: + globals.showRegionNames ?? currentPreferences.showRegionNames, + customColorValues: globals.hasCustomColorValues + ? globals.customColorValues + : currentPreferences.customColorValues, + ), + ); + + await favoriteAgentsBox.clear(); + for (final favorite in globals.favoriteAgentTypes()) { + await favoriteAgentsBox.put(favorite.name, true); + } + + await ref.read(mapThemeProfilesProvider.notifier).refreshFromHive(); + await ref.read(appPreferencesProvider.notifier).refreshFromHive(); + ref.invalidate(favoriteAgentsProvider); + + return _GlobalImportResult( + themeProfilesImported: themeProfilesImported, + globalStateRestored: true, + profileIdRemap: profileIdRemap, + ); + } + + Future _importStrategyFile({ + required XFile file, + required String? targetFolderId, + String? displayNameOverride, + Map themeProfileIdRemap = const {}, + }) async { + final newID = const Uuid().v4(); + final isZip = await isZipFile(File(file.path)); + + log('Is ZIP file: $isZip'); + final bytes = await file.readAsBytes(); + String jsonData = ''; + + try { + if (isZip) { + final archive = ZipDecoder().decodeBytes(bytes); + + final imageFolder = await PlacedImageProvider.getImageFolder(newID); + final tempDirectory = await getTempDirectory(newID); + + await _extractArchiveEntriesToDisk( + archive: archive, + destination: tempDirectory, + ); + + final tempDirectoryList = tempDirectory.listSync(); + log('Temp directory list: ${tempDirectoryList.length}.'); + + for (final fileEntity in tempDirectoryList) { + if (fileEntity is File) { + log(fileEntity.path); + if (path.extension(fileEntity.path) == '.json') { + log('Found JSON file'); + jsonData = await fileEntity.readAsString(); + } else if (path.extension(fileEntity.path) != '.ica') { + final fileName = path.basename(fileEntity.path); + await fileEntity.copy(path.join(imageFolder.path, fileName)); + } + } + } + if (jsonData.isEmpty) { + throw Exception('No .ica file found'); + } + } else { + jsonData = await file.readAsString(); + } + + final json = jsonDecode(jsonData) as Map; + final versionNumber = int.tryParse(json['versionNumber'].toString()) ?? + Settings.versionNumber; + _throwIfImportedVersionIsTooNew(versionNumber); + + final drawingData = + DrawingProvider.fromJson(jsonEncode(json['drawingData'] ?? [])); + final agentData = + AgentProvider.fromJson(jsonEncode(json['agentData'] ?? [])) + .whereType() + .toList(growable: false); + final abilityData = + AbilityProvider.fromJson(jsonEncode(json['abilityData'] ?? [])); + final mapData = MapProvider.fromJson(jsonEncode(json['mapData'])); + final textData = + TextProvider.fromJson(jsonEncode(json['textData'] ?? [])); + + List imageData = []; + if (!kIsWeb) { + if (isZip) { + imageData = await PlacedImageProvider.fromJson( + jsonString: jsonEncode(json['imageData'] ?? []), + strategyID: newID, + ); + } else { + log('Legacy image data loading'); + imageData = await PlacedImageProvider.legacyFromJson( + jsonString: jsonEncode(json['imageData'] ?? []), + strategyID: newID, + ); + } + } + + final StrategySettings settingsData; + final bool isAttack; + final List utilityData; + + if (json['settingsData'] != null) { + settingsData = ref + .read(strategySettingsProvider.notifier) + .fromJson(jsonEncode(json['settingsData'])); + } else { + settingsData = StrategySettings(); + } + + if (json['isAttack'] != null) { + isAttack = json['isAttack'] == 'true' ? true : false; + } else { + isAttack = true; + } + + if (json['utilityData'] != null) { + utilityData = UtilityProvider.fromJson(jsonEncode(json['utilityData'])); + } else { + utilityData = []; + } + + final importedThemeOverridePalette = + json['themePalette'] is Map + ? MapThemePalette.fromJson(json['themePalette']) + : (json['themePalette'] is Map + ? MapThemePalette.fromJson( + Map.from(json['themePalette']), + ) + : null); + final rawImportedThemeProfileId = json['themeProfileId']; + final importedThemeProfileId = rawImportedThemeProfileId is String && + rawImportedThemeProfileId.isNotEmpty + ? rawImportedThemeProfileId + : null; + final resolvedThemeProfileId = importedThemeProfileId == null + ? null + : (themeProfileIdRemap[importedThemeProfileId] ?? + importedThemeProfileId); + + final pages = json['pages'] != null + ? await StrategyPage.listFromJson( + json: jsonEncode(json['pages']), + strategyID: newID, + isZip: isZip, + ) + : []; + + var newStrategy = StrategyData( + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + drawingData: drawingData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + agentData: agentData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + abilityData: abilityData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + textData: textData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + imageData: imageData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + utilityData: utilityData, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + isAttack: isAttack, + // ignore: deprecated_member_use_from_same_package, deprecated_member_use + strategySettings: settingsData, + pages: pages, + id: newID, + name: displayNameOverride ?? path.basenameWithoutExtension(file.name), + mapData: mapData, + versionNumber: versionNumber, + lastEdited: DateTime.now(), + folderID: targetFolderId, + themeProfileId: resolvedThemeProfileId, + themeOverridePalette: resolvedThemeProfileId == null + ? importedThemeOverridePalette + : null, + ); + + newStrategy = await StrategyMigrator.migrateLegacyData(newStrategy); + + await Hive.box(HiveBoxNames.strategiesBox) + .put(newStrategy.id, newStrategy); + } finally { + if (isZip) { + try { + await cleanUpTempDirectory(newID); + } catch (_) {} + } + } + } + + static bool isNewerVersionImportError(Object error) { + return error is NewerVersionImportException; + } + + @visibleForTesting + static void throwIfImportedVersionIsTooNewForTest(int importedVersion) { + _throwIfImportedVersionIsTooNew(importedVersion); + } + + static void _throwIfImportedVersionIsTooNew(int importedVersion) { + if (importedVersion <= Settings.versionNumber) { + return; + } + + throw NewerVersionImportException( + importedVersion: importedVersion, + currentVersion: Settings.versionNumber, + ); + } + + Future _flushCurrentStrategyIfNeeded() async { + final strategyState = ref.read(strategyProvider); + final strategyId = strategyState.strategyId; + if (strategyState.strategyName == null || strategyId == null) { + return; + } + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + } + + Future exportFolder(String folderID) async { + final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); + if (folder == null) { + log("Couldn't find folder to export"); + return; + } + + await _flushCurrentStrategyIfNeeded(); + final stagingDirectory = await buildFolderExportDirectoryForTest(folderID); + + try { + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: '${sanitizeStrategyFileName(folder.name)}.zip', + allowedExtensions: ['zip'], + ); + + if (outputFile == null) return; + + final encoder = ZipFileEncoder(); + encoder.create(outputFile); + await encoder.addDirectory(stagingDirectory, includeDirName: false); + await encoder.close(); + } finally { + try { + await stagingDirectory.delete(recursive: true); + } catch (_) {} + } + } + + Future exportLibrary() async { + await _flushCurrentStrategyIfNeeded(); + final stagingDirectory = await buildLibraryExportDirectoryForTest(); + + try { + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: buildLibraryBackupFileName(DateTime.now()), + allowedExtensions: ['zip'], + ); + + if (outputFile == null) return; + + final encoder = ZipFileEncoder(); + encoder.create(outputFile); + await encoder.addDirectory(stagingDirectory, includeDirName: false); + await encoder.close(); + } finally { + try { + await stagingDirectory.delete(recursive: true); + } catch (_) {} + } + } + + @visibleForTesting + Future buildFolderExportDirectoryForTest(String folderID) async { + final folder = Hive.box(HiveBoxNames.foldersBox).get(folderID); + if (folder == null) { + throw StateError("Couldn't find folder to export"); + } + + final stagingDirectory = + await Directory.systemTemp.createTemp('icarus-folder-export'); + final rootDirectory = await _createUniqueChildDirectory( + parentDirectory: stagingDirectory, + desiredName: folder.name, + ); + final exportState = _ArchiveExportState(rootDirectory: rootDirectory); + await _writeFolderArchive( + folderID: folderID, + exportDirectory: rootDirectory, + exportState: exportState, + parentManifestId: null, + currentArchivePath: '', + ); + await _writeArchiveManifest( + exportState: exportState, + archiveType: ArchiveType.folderTree, + ); + return stagingDirectory; + } + + @visibleForTesting + Future buildLibraryExportDirectoryForTest() async { + final stagingDirectory = + await Directory.systemTemp.createTemp('icarus-library-export'); + final rootDirectory = Directory( + path.join(stagingDirectory.path, libraryBackupRootDirectoryName), + ); + await rootDirectory.create(recursive: true); + final rootStrategiesDirectory = + Directory(path.join(rootDirectory.path, 'root_strategies')) + ..createSync(recursive: true); + final foldersDirectory = Directory(path.join(rootDirectory.path, 'folders')) + ..createSync(recursive: true); + + final exportState = _ArchiveExportState(rootDirectory: rootDirectory); + + for (final strategy in _sortedStrategiesForFolder(null)) { + final strategyArchivePath = await zipStrategy( + id: strategy.id, + saveDir: rootStrategiesDirectory, + ); + exportState.strategies.add( + ArchiveStrategyEntry( + name: strategy.name, + archivePath: normalizeArchivePath(path.posix.join( + 'root_strategies', + path.basename(strategyArchivePath), + )), + folderManifestId: null, + ), + ); + } + + for (final rootFolder in _sortedFoldersForParent(null)) { + final rootFolderDirectory = await _createUniqueChildDirectory( + parentDirectory: foldersDirectory, + desiredName: rootFolder.name, + ); + final rootArchivePath = normalizeArchivePath(path.posix.join( + 'folders', + path.basename(rootFolderDirectory.path), + )); + await _writeFolderArchive( + folderID: rootFolder.id, + exportDirectory: rootFolderDirectory, + exportState: exportState, + parentManifestId: null, + currentArchivePath: rootArchivePath, + ); + } + + await _writeArchiveManifest( + exportState: exportState, + archiveType: ArchiveType.libraryBackup, + globals: _buildLibraryGlobals(), + ); + return stagingDirectory; + } + + Future _createUniqueChildDirectory({ + required Directory parentDirectory, + required String desiredName, + }) async { + final sanitizedName = sanitizeStrategyFileName(desiredName); + var candidate = sanitizedName; + var counter = 1; + var directory = Directory(path.join(parentDirectory.path, candidate)); + while (await directory.exists()) { + candidate = '${sanitizedName}_$counter'; + counter++; + directory = Directory(path.join(parentDirectory.path, candidate)); + } + await directory.create(recursive: true); + return directory; + } + + Future _writeFolderArchive({ + required String folderID, + required Directory exportDirectory, + required _ArchiveExportState exportState, + required String? parentManifestId, + required String currentArchivePath, + }) async { + final currentFolder = + ref.read(folderProvider.notifier).findFolderByID(folderID); + if (currentFolder == null) { + return; + } + + final manifestId = const Uuid().v4(); + exportState.folders.add( + ArchiveFolderEntry( + manifestId: manifestId, + name: currentFolder.name, + parentManifestId: parentManifestId, + archivePath: normalizeArchivePath(currentArchivePath), + iconId: currentFolder.iconId, + icon: ArchiveIconDescriptor.fromIconData(currentFolder.icon), + color: currentFolder.color, + customColorValue: currentFolder.customColor?.toARGB32(), + ), + ); + + for (final strategy in _sortedStrategiesForFolder(folderID)) { + final strategyArchivePath = await zipStrategy( + id: strategy.id, + saveDir: exportDirectory, + ); + exportState.strategies.add( + ArchiveStrategyEntry( + name: strategy.name, + archivePath: normalizeArchivePath(path.posix.join( + currentArchivePath, + path.basename(strategyArchivePath), + )), + folderManifestId: manifestId, + ), + ); + } + + for (final subFolder in _sortedFoldersForParent(folderID)) { + final childDirectory = await _createUniqueChildDirectory( + parentDirectory: exportDirectory, + desiredName: subFolder.name, + ); + final childArchivePath = normalizeArchivePath(path.posix.join( + currentArchivePath, + path.basename(childDirectory.path), + )); + await _writeFolderArchive( + folderID: subFolder.id, + exportDirectory: childDirectory, + exportState: exportState, + parentManifestId: manifestId, + currentArchivePath: childArchivePath, + ); + } + } + + List _sortedStrategiesForFolder(String? folderID) { + final strategies = Hive.box(HiveBoxNames.strategiesBox) + .values + .where((strategy) => strategy.folderID == folderID) + .toList(); + strategies.sort((a, b) { + final nameCompare = a.name.compareTo(b.name); + if (nameCompare != 0) { + return nameCompare; + } + return a.id.compareTo(b.id); + }); + return strategies; + } + + List _sortedFoldersForParent(String? parentID) { + final folders = Hive.box(HiveBoxNames.foldersBox) + .values + .where((folder) => folder.parentID == parentID) + .toList(); + folders.sort((a, b) { + final nameCompare = a.name.compareTo(b.name); + if (nameCompare != 0) { + return nameCompare; + } + return a.id.compareTo(b.id); + }); + return folders; + } + + ArchiveGlobals _buildLibraryGlobals() { + final profiles = Hive.box(HiveBoxNames.mapThemeProfilesBox) + .values + .map( + (profile) => ArchiveThemeProfileEntry( + id: profile.id, + name: profile.name, + palette: profile.palette, + isBuiltIn: profile.isBuiltIn, + ), + ) + .toList(growable: false); + final appPreferences = + Hive.box(HiveBoxNames.appPreferencesBox) + .get(MapThemeProfilesProvider.appPreferencesSingletonKey); + final favoriteAgents = Hive.box(HiveBoxNames.favoriteAgentsBox) + .keys + .whereType() + .toList() + ..sort(); + + return ArchiveGlobals( + themeProfiles: profiles, + defaultThemeProfileIdForNewStrategies: + appPreferences?.defaultThemeProfileIdForNewStrategies, + showSpawnBarrier: appPreferences?.showSpawnBarrier, + showUltOrbs: appPreferences?.showUltOrbs, + showRegionNames: appPreferences?.showRegionNames, + customColorValues: appPreferences?.customColorValues ?? const [], + favoriteAgents: favoriteAgents, + ); + } + + Future _writeArchiveManifest({ + required _ArchiveExportState exportState, + required ArchiveType archiveType, + ArchiveGlobals? globals, + }) async { + final manifest = ArchiveManifest( + schemaVersion: archiveManifestSchemaVersion, + archiveType: archiveType, + exportedAt: DateTime.now().toUtc(), + appVersionNumber: Settings.versionNumber, + folders: exportState.folders, + strategies: exportState.strategies, + globals: globals, + ); + + final manifestFile = File( + path.join(exportState.rootDirectory.path, archiveMetadataFileName), + ); + await manifestFile.writeAsString( + const JsonEncoder.withIndent(' ').convert(manifest.toJson()), + ); + } + + MapThemePalette _resolveThemePaletteForExport(StrategyData strategy) { + if (strategy.themeOverridePalette != null) { + return strategy.themeOverridePalette!; + } + + final profiles = + Hive.box(HiveBoxNames.mapThemeProfilesBox); + final assignedProfile = strategy.themeProfileId == null + ? null + : profiles.get(strategy.themeProfileId!); + if (assignedProfile != null) { + return assignedProfile.palette; + } + + return MapThemeProfilesProvider.immutableDefaultPalette; + } + + Future zipStrategy({ + required String id, + Directory? saveDir, + String? outputFilePath, + }) async { + final strategy = Hive.box(HiveBoxNames.strategiesBox).get(id); + if (strategy == null) { + log("Couldn't find strategy to export"); + throw StateError("Couldn't find strategy to export"); + } + return zipStrategyData( + strategy: strategy, + saveDir: saveDir, + outputFilePath: outputFilePath, + ); + } + + Future zipStrategyData({ + required StrategyData strategy, + Directory? saveDir, + String? outputFilePath, + }) async { + final payload = { + 'versionNumber': '${Settings.versionNumber}', + 'mapData': '${Maps.mapNames[strategy.mapData]}', + 'themePalette': _resolveThemePaletteForExport(strategy).toJson(), + if (strategy.themeProfileId != null) + 'themeProfileId': strategy.themeProfileId, + 'pages': strategy.pages.map((page) => page.toJson(strategy.id)).toList(), + }; + final data = jsonEncode(payload); + + final sanitizedStrategyName = sanitizeStrategyFileName(strategy.name); + + late final String outPath; + late final String archiveBase; + if (outputFilePath != null) { + outPath = outputFilePath; + archiveBase = path.basenameWithoutExtension(outPath); + } else { + final base = sanitizedStrategyName; + var candidate = base; + var index = 1; + while (File(path.join(saveDir!.path, '$candidate.ica')).existsSync()) { + candidate = '${base}_$index'; + index++; + } + archiveBase = candidate; + outPath = path.join(saveDir.path, '$archiveBase.ica'); + } + + final jsonArchiveFile = + ArchiveFile.bytes('$archiveBase.json', utf8.encode(data)); + + final zipEncoder = ZipFileEncoder()..create(outPath); + + final supportDirectory = + await _getApplicationSupportDirectoryOrSystemTemp(); + final customDirectory = + Directory(path.join(supportDirectory.path, strategy.id)); + final imagesDirectory = + Directory(path.join(customDirectory.path, 'images')); + await imagesDirectory.create(recursive: true); + + await for (final entity in imagesDirectory.list()) { + if (entity is File) { + await zipEncoder.addFile(entity); + } + } + + zipEncoder.addArchiveFile(jsonArchiveFile); + await zipEncoder.close(); + return outPath; + } + + Future _ensureRemoteAssetsCached( + RemoteFullStrategySnapshot snapshot, + ) async { + final strategyId = snapshot.header.publicId; + final assetIds = {}; + for (final fullPage in snapshot.pages) { + final page = fullPage.page; + for (final element + in snapshot.elementsByPage[page.publicId] ?? const []) { + if (element.deleted || element.elementType != 'image') { + continue; + } + assetIds.add(element.publicId); + } + + final lineups = lineUpGraphFromRemoteLineups( + snapshot.lineupsByPage[page.publicId] ?? const [], + ); + for (final link in lineups.links) { + for (final image in link.images) { + assetIds.add(image.id); + } + } + } + + final cacheNotifier = ref.read(cloudMediaCacheProvider.notifier); + final cached = await cacheNotifier.ensureAssetIdsCached( + strategyId: strategyId, + strategyPublicId: strategyId, + assetsById: snapshot.assetsById, + assetIds: assetIds, + ); + if (!cached) { + throw StateError( + 'Unable to cache all remote images for export. Reconnect and retry.', + ); + } + } + + Future exportCloudStrategy(String strategyId) async { + final snapshot = await ref + .read(convexStrategyRepositoryProvider) + .fetchFullSnapshot(strategyId); + await _ensureRemoteAssetsCached(snapshot); + final strategy = strategyDataFromRemoteSnapshot(snapshot); + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: '${sanitizeStrategyFileName(strategy.name)}.ica', + allowedExtensions: ['ica'], + ); + if (outputFile == null) return false; + await zipStrategyData(strategy: strategy, outputFilePath: outputFile); + return true; + } + + Future exportFile(String id) async { + await ref.read(strategyProvider.notifier).forceSaveNow(id); + + final outputFile = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: + '${sanitizeStrategyFileName(ref.read(strategyProvider).strategyName ?? "new strategy")}.ica', + allowedExtensions: ['ica'], + ); + + if (outputFile == null) return; + await zipStrategy(id: id, outputFilePath: outputFile); + } + + /// A cloud strategy as the library models it, from the server's full copy. + static StrategyData strategyDataFromRemoteSnapshot( + RemoteFullStrategySnapshot snapshot, + ) { + final pages = []; + final mapValue = Maps.mapNames.entries + .where((entry) => entry.value == snapshot.header.mapData) + .map((entry) => entry.key) + .first; + + final orderedPages = [...snapshot.pages] + ..sortBySortIndex((item) => item.page.sortIndex); + for (final fullPage in orderedPages) { + final remotePage = fullPage.page; + final elements = snapshot.elementsByPage[remotePage.publicId] ?? const []; + final lineups = snapshot.lineupsByPage[remotePage.publicId] ?? const []; + final drawingData = []; + final agentData = []; + final abilityData = []; + final textData = []; + final imageData = []; + final utilityData = []; + + for (final element in elements) { + if (element.deleted) continue; + final payload = element.decodedPayload(); + try { + switch (element.elementType) { + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) drawingData.add(decoded.first); + break; + case 'agent': + agentData.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilityData.add(PlacedAbility.fromJson(payload)); + break; + case 'text': + textData.add(PlacedText.fromJson(payload)); + break; + case 'image': + imageData.add(PlacedImage.fromJson(payload)); + break; + case 'utility': + utilityData.add(PlacedUtility.fromJson(payload)); + break; + default: + throw FormatException( + 'Unsupported cloud element type: ${element.elementType}', + ); + } + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FormatException( + 'Cloud element ${element.publicId} could not be exported: $error', + ), + stackTrace, + ); + } + } + + final lineUpGraph = lineUpGraphFromRemoteLineups(lineups); + + StrategySettings settings = StrategySettings(); + final settingsPayload = fullPage.content.settings; + if (settingsPayload != null && settingsPayload.isNotEmpty) { + try { + settings = StrategySettings.fromJson(settingsPayload); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FormatException( + 'Cloud page ${remotePage.publicId} settings could not be ' + 'exported: $error', + ), + stackTrace, + ); + } + } + + pages.add( + StrategyPage( + id: remotePage.publicId, + name: remotePage.name, + isAutoNamed: remotePage.isAutoNamed, + drawingData: drawingData, + agentData: agentData, + abilityData: abilityData, + textData: textData, + imageData: imageData, + utilityData: utilityData, + sortIndex: remotePage.sortIndex, + isAttack: remotePage.isAttack, + settings: settings, + lineUpOrigins: lineUpGraph.origins, + lineUpLandings: lineUpGraph.landings, + lineUpLinks: lineUpGraph.links, + ), + ); + } + + MapThemePalette? overridePalette; + final rawPalette = snapshot.header.themeOverridePalette; + if (rawPalette != null && rawPalette.isNotEmpty) { + try { + overridePalette = MapThemePalette.fromJson(rawPalette); + } catch (error, stackTrace) { + Error.throwWithStackTrace( + FormatException( + 'Cloud strategy ${snapshot.header.publicId} theme could not be ' + 'exported: $error', + ), + stackTrace, + ); + } + } + + return StrategyData( + id: snapshot.header.publicId, + name: snapshot.header.name, + mapData: mapValue, + versionNumber: Settings.versionNumber, + lastEdited: snapshot.header.updatedAt, + createdAt: snapshot.header.createdAt, + folderID: null, + themeProfileId: snapshot.header.themeProfileId, + themeOverridePalette: overridePalette, + pages: pages, + ); + } +} diff --git a/lib/strategy/strategy_migrator.dart b/lib/strategy/strategy_migrator.dart new file mode 100644 index 00000000..984d0d47 --- /dev/null +++ b/lib/strategy/strategy_migrator.dart @@ -0,0 +1,575 @@ +import 'package:flutter/material.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/abilities.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/migrations/ability_vision_cone_migration.dart'; +import 'package:icarus/migrations/agent_weapon_migration.dart'; +import 'package:icarus/migrations/ability_scale_migration.dart'; +import 'package:icarus/migrations/canonical_coordinates_migration.dart'; +import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; +import 'package:icarus/migrations/lineup_graph_migration.dart'; +import 'package:icarus/migrations/lineup_group_migration.dart'; +import 'package:icarus/migrations/page_name_provenance_migration.dart'; +import 'package:icarus/migrations/paranoia_range_migration.dart'; +import 'package:icarus/migrations/sunset_scale_migration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:uuid/uuid.dart'; + +class StrategyMigrator { + /// The startup pass over the on-device library. Where [policy] hides that + /// library, its records stay exactly as their version wrote them; they + /// migrate when local access returns. + static Future migrateLocalLibrary(PlatformPolicy policy) async { + if (!policy.allowsLocalLibrary) return; + await migrateAllStrategies(); + } + + static Future migrateAllStrategies() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + for (final strat in box.values) { + final legacyMigrated = await migrateLegacyData(strat); + final migrated = migrateToCurrentVersion(legacyMigrated); + if (migrated != strat) { + await box.put(migrated.id, migrated); + } + } + } + + static StrategyData migrateAbilityScale(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= AbilityScaleMigration.version) { + return strat; + } + + final migratedPages = AbilityScaleMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateSquareAoeCenter(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= SquareAoeCenterMigration.version) { + return strat; + } + + final migratedPages = SquareAoeCenterMigration.migratePages( + pages: strat.pages, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateCustomCircleWrapper(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= CustomCircleWrapperMigration.version) { + return strat; + } + + final migratedPages = CustomCircleWrapperMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ); + + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateLineUpGroups(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= LineUpGroupMigration.version) { + return strat; + } + + final migratedPages = LineUpGroupMigration.migratePages( + pages: strat.pages, + ); + final hasPageChanged = migratedPages.length == strat.pages.length && + migratedPages.asMap().entries.any((entry) { + final index = entry.key; + return entry.value != strat.pages[index]; + }); + + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: migratedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateToCurrentVersion( + StrategyData strat, { + bool forceAbilityScale = false, + }) { + final originalVersion = strat.versionNumber; + final needsCanonicalCoordinatesMigration = + strat.versionNumber < CanonicalCoordinatesMigration.version; + final needsAbilityVisionMigration = + strat.versionNumber < AbilityVisionConeMigration.version; + final needsPageNameProvenanceMigration = + strat.versionNumber < PageNameProvenanceMigration.version; + final worldMigrated = migrateToWorld16x9(strat); + final abilityScaleMigrated = migrateAbilityScale( + worldMigrated, + force: + forceAbilityScale || originalVersion < AbilityScaleMigration.version, + ); + final squareAoeMigrated = migrateSquareAoeCenter( + abilityScaleMigrated, + force: originalVersion < SquareAoeCenterMigration.version, + ); + final customCircleMigrated = migrateCustomCircleWrapper( + squareAoeMigrated, + force: originalVersion < CustomCircleWrapperMigration.version, + ); + final lineUpGroupMigrated = migrateLineUpGroups( + customCircleMigrated, + force: originalVersion < LineUpGroupMigration.version, + ); + final lineUpGraphMigrated = migrateLineUpGraph( + lineUpGroupMigrated, + force: originalVersion < LineUpGraphMigration.version, + ); + final abilityVisionMigrated = migrateAbilityVisionCones( + lineUpGraphMigrated, + force: needsAbilityVisionMigration, + ); + final pageNameMigrated = migratePageNameProvenance( + abilityVisionMigrated, + force: needsPageNameProvenanceMigration, + ); + final canonicalMigrated = migrateCanonicalCoordinates( + pageNameMigrated, + force: needsCanonicalCoordinatesMigration, + ); + final sunsetMigrated = migrateSunsetScale( + canonicalMigrated, + force: originalVersion < SunsetScaleMigration.version, + ); + final paranoiaMigrated = migrateParanoiaRange( + sunsetMigrated, + force: originalVersion < ParanoiaRangeMigration.version, + ); + final migrated = AgentWeaponMigration.migrate(paranoiaMigrated); + // Releases without a schema change still finish at the current version. + // Never downgrade a strategy written by a newer release. + return migrated.versionNumber < Settings.versionNumber + ? migrated.copyWith(versionNumber: Settings.versionNumber) + : migrated; + } + + static StrategyData migrateSunsetScale( + StrategyData strat, { + bool force = false, + }) { + if (!force && strat.versionNumber >= SunsetScaleMigration.version) { + return strat; + } + return strat.copyWith( + pages: SunsetScaleMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateParanoiaRange( + StrategyData strat, { + bool force = false, + }) { + if (!force && strat.versionNumber >= ParanoiaRangeMigration.version) { + return strat; + } + final pages = ParanoiaRangeMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ); + if (identical(pages, strat.pages)) return strat; + return strat.copyWith( + pages: pages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateLineUpGraph(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= LineUpGraphMigration.version) { + return strat; + } + + final migratedPages = LineUpGraphMigration.migratePages(pages: strat.pages); + final hasPageChanged = migratedPages.asMap().entries.any( + (entry) => !identical(entry.value, strat.pages[entry.key]), + ); + if (!hasPageChanged && !force) { + return strat; + } + + return strat.copyWith( + pages: hasPageChanged ? migratedPages : strat.pages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateCanonicalCoordinates( + StrategyData strat, { + bool force = false, + }) { + if (!force && + strat.versionNumber >= CanonicalCoordinatesMigration.version) { + return strat; + } + return strat.copyWith( + pages: CanonicalCoordinatesMigration.migratePages( + pages: strat.pages, + map: strat.mapData, + ), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migrateAbilityVisionCones( + StrategyData strat, { + bool force = false, + }) { + if (!force && strat.versionNumber >= AbilityVisionConeMigration.version) { + return strat; + } + return strat.copyWith( + pages: AbilityVisionConeMigration.migratePages(pages: strat.pages), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static StrategyData migratePageNameProvenance( + StrategyData strat, { + bool force = false, + }) { + if (!force && strat.versionNumber >= PageNameProvenanceMigration.version) { + return strat; + } + return strat.copyWith( + pages: PageNameProvenanceMigration.migratePages(pages: strat.pages), + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + } + + static Future migrateLegacyData(StrategyData strat) async { + if (strat.pages.isNotEmpty) { + return migrateToCurrentVersion(strat); + } + if (strat.versionNumber > 15) { + return migrateToCurrentVersion(strat); + } + final originalVersion = strat.versionNumber; + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + final abilityData = [...strat.abilityData]; + if (strat.versionNumber < 7) { + for (final a in abilityData) { + if (a.data.abilityData! is SquareAbility) { + a.position = a.position.translate(0, -7.5); + } + } + } + + final firstPage = StrategyPage( + id: const Uuid().v4(), + name: 'Page 1', + isAutoNamed: true, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + drawingData: [...strat.drawingData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + agentData: [...strat.agentData], + abilityData: abilityData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + textData: [...strat.textData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + imageData: [...strat.imageData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + utilityData: [...strat.utilityData], + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + isAttack: strat.isAttack, + // ignore: deprecated_member_use_from_same_package + settings: strat.strategySettings, + sortIndex: 0, + ); + + final updated = strat.copyWith( + pages: [firstPage], + agentData: [], + abilityData: [], + drawingData: [], + utilityData: [], + textData: [], + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + + final worldMigrated = migrateToWorld16x9(updated, + force: originalVersion < Settings.versionNumber); + final abilityScaleMigrated = migrateAbilityScale( + worldMigrated, + force: originalVersion < AbilityScaleMigration.version, + ); + final squareAoeMigrated = migrateSquareAoeCenter( + abilityScaleMigrated, + force: originalVersion < SquareAoeCenterMigration.version, + ); + final customCircleMigrated = migrateCustomCircleWrapper( + squareAoeMigrated, + force: originalVersion < CustomCircleWrapperMigration.version, + ); + final lineUpGroupMigrated = migrateLineUpGroups( + customCircleMigrated, + force: originalVersion < LineUpGroupMigration.version, + ); + final lineUpGraphMigrated = migrateLineUpGraph( + lineUpGroupMigrated, + force: originalVersion < LineUpGraphMigration.version, + ); + final abilityVisionMigrated = migrateAbilityVisionCones( + lineUpGraphMigrated, + force: originalVersion < AbilityVisionConeMigration.version, + ); + final pageNameMigrated = migratePageNameProvenance( + abilityVisionMigrated, + force: originalVersion < PageNameProvenanceMigration.version, + ); + final canonicalMigrated = migrateCanonicalCoordinates( + pageNameMigrated, + force: originalVersion < CanonicalCoordinatesMigration.version, + ); + final sunsetMigrated = migrateSunsetScale(canonicalMigrated, + force: originalVersion < SunsetScaleMigration.version); + return migrateParanoiaRange(sunsetMigrated, + force: originalVersion < ParanoiaRangeMigration.version); + } + + static StrategyData migrateToWorld16x9(StrategyData strat, + {bool force = false}) { + if (!force && strat.versionNumber >= 38) return strat; + + const double normalizedHeight = 1000.0; + const double mapAspectRatio = 1.24; + const double worldAspectRatio = 16 / 9; + const mapWidth = normalizedHeight * mapAspectRatio; + const worldWidth = normalizedHeight * worldAspectRatio; + const padding = (worldWidth - mapWidth) / 2; + + Offset shift(Offset offset) => offset.translate(padding, 0); + + List shiftAgentNodes(List agents) { + return [ + for (final agent in agents) + switch (agent) { + PlacedAgent() => agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + PlacedViewConeAgent() => + agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + PlacedCircleAgent() => + agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + }, + ]; + } + + List shiftAbilities(List abilities) { + return [ + for (final ability in abilities) + ability.copyWith(position: shift(ability.position)) + ..isDeleted = ability.isDeleted + ]; + } + + List shiftTexts(List texts) { + return [ + for (final text in texts) + text.copyWith( + position: shift(text.position), + ) + ]; + } + + List shiftImages(List images) { + return [ + for (final image in images) + image.copyWith(position: shift(image.position)) + ..isDeleted = image.isDeleted + ]; + } + + List shiftUtilities(List utilities) { + return [ + for (final utility in utilities) + PlacedUtility( + type: utility.type, + position: shift(utility.position), + id: utility.id, + angle: utility.angle, + customDiameter: utility.customDiameter, + customWidth: utility.customWidth, + customLength: utility.customLength, + customColorValue: utility.customColorValue, + customOpacityPercent: utility.customOpacityPercent, + ) + ..rotation = utility.rotation + ..length = utility.length + ..isDeleted = utility.isDeleted + ]; + } + + LineUpGraph shiftLineUpGraph(LineUpGraph graph) { + return graph.mapNodes( + agent: (agent) => agent.copyWith(position: shift(agent.position)) + ..isDeleted = agent.isDeleted, + ability: (ability) => + ability.copyWith(position: shift(ability.position)) + ..isDeleted = ability.isDeleted, + ); + } + + BoundingBox? shiftBoundingBox(BoundingBox? boundingBox) { + if (boundingBox == null) return null; + return BoundingBox( + min: shift(boundingBox.min), + max: shift(boundingBox.max), + ); + } + + List shiftDrawings(List drawings) { + return drawings + .map((element) { + if (element is Line) { + return Line( + lineStart: shift(element.lineStart), + lineEnd: shift(element.lineEnd), + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + showTraversalTime: element.showTraversalTime, + traversalSpeedProfile: element.traversalSpeedProfile, + ); + } + if (element is FreeDrawing) { + final shiftedPoints = + element.listOfPoints.map(shift).toList(growable: false); + + return FreeDrawing( + listOfPoints: shiftedPoints, + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + showTraversalTime: element.showTraversalTime, + traversalSpeedProfile: element.traversalSpeedProfile, + ); + } + if (element is RectangleDrawing) { + return RectangleDrawing( + start: shift(element.start), + end: shift(element.end), + color: element.color, + thickness: element.thickness, + boundingBox: shiftBoundingBox(element.boundingBox), + isDotted: element.isDotted, + hasArrow: element.hasArrow, + id: element.id, + ); + } + return element; + }) + .cast() + .toList(growable: false); + } + + final updatedPages = strat.pages + .map((page) => page.copyWith( + sortIndex: page.sortIndex, + name: page.name, + id: page.id, + agentData: shiftAgentNodes(page.agentData), + abilityData: shiftAbilities(page.abilityData), + textData: shiftTexts(page.textData), + imageData: shiftImages(page.imageData), + utilityData: shiftUtilities(page.utilityData), + drawingData: shiftDrawings(page.drawingData), + lineUpGraph: shiftLineUpGraph(page.lineUpGraph), + )) + .toList(growable: false); + + final migrated = strat.copyWith( + pages: updatedPages, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.now(), + ); + + return migrated; + } +} diff --git a/lib/strategy/strategy_models.dart b/lib/strategy/strategy_models.dart new file mode 100644 index 00000000..f9345bfe --- /dev/null +++ b/lib/strategy/strategy_models.dart @@ -0,0 +1,191 @@ +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class StrategyData extends HiveObject { + final String id; + String name; + final int versionNumber; + + @Deprecated('Use pages instead') + final List drawingData; + + @Deprecated('Use pages instead') + final List agentData; + + @Deprecated('Use pages instead') + final List abilityData; + + @Deprecated('Use pages instead') + final List textData; + + @Deprecated('Use pages instead') + final List imageData; + + @Deprecated('Use pages instead') + final List utilityData; + + @Deprecated('Use pages instead') + final bool isAttack; + + @Deprecated('Use pages instead') + final StrategySettings strategySettings; + + final List pages; + final MapValue mapData; + final DateTime lastEdited; + final DateTime createdAt; + + String? folderID; + final String? themeProfileId; + final MapThemePalette? themeOverridePalette; + + StrategyData({ + @Deprecated('Use pages instead') this.isAttack = true, + @Deprecated('Use pages instead') this.drawingData = const [], + @Deprecated('Use pages instead') this.agentData = const [], + @Deprecated('Use pages instead') this.abilityData = const [], + @Deprecated('Use pages instead') this.textData = const [], + @Deprecated('Use pages instead') this.imageData = const [], + @Deprecated('Use pages instead') this.utilityData = const [], + required this.id, + required this.name, + required this.mapData, + required this.versionNumber, + required this.lastEdited, + required this.folderID, + this.themeProfileId, + this.themeOverridePalette, + this.pages = const [], + DateTime? createdAt, + @Deprecated('Use pages instead') StrategySettings? strategySettings, + // ignore: deprecated_member_use_from_same_package + }) : strategySettings = strategySettings ?? StrategySettings(), + createdAt = createdAt ?? lastEdited; + + StrategyData copyWith({ + String? id, + String? name, + int? versionNumber, + List? drawingData, + List? agentData, + List? abilityData, + List? textData, + List? imageData, + List? utilityData, + List? pages, + MapValue? mapData, + DateTime? lastEdited, + bool? isAttack, + StrategySettings? strategySettings, + String? folderID, + DateTime? createdAt, + String? themeProfileId, + bool clearThemeProfileId = false, + MapThemePalette? themeOverridePalette, + bool clearThemeOverridePalette = false, + }) { + return StrategyData( + id: id ?? this.id, + name: name ?? this.name, + versionNumber: versionNumber ?? this.versionNumber, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + drawingData: drawingData ?? this.drawingData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + agentData: agentData ?? this.agentData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + abilityData: abilityData ?? this.abilityData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + textData: textData ?? this.textData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + imageData: imageData ?? this.imageData, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + utilityData: utilityData ?? this.utilityData, + pages: pages ?? this.pages, + mapData: mapData ?? this.mapData, + lastEdited: lastEdited ?? this.lastEdited, + // ignore: deprecated_member_use, deprecated_member_use_from_same_package + isAttack: isAttack ?? this.isAttack, + // ignore: deprecated_member_use_from_same_package + strategySettings: strategySettings ?? this.strategySettings, + createdAt: createdAt ?? this.createdAt, + folderID: folderID ?? this.folderID, + themeProfileId: + clearThemeProfileId ? null : (themeProfileId ?? this.themeProfileId), + themeOverridePalette: clearThemeOverridePalette + ? null + : (themeOverridePalette ?? this.themeOverridePalette), + ); + } +} + +class StrategyState { + const StrategyState({ + String? strategyId, + String? strategyName, + StrategySource? source, + this.storageDirectory, + bool isOpen = false, + String? id, + String? stratName, + this.isSaved = true, + this.activePageId, + }) : strategyId = strategyId ?? id, + strategyName = strategyName ?? stratName, + source = source ?? + ((strategyId ?? id) == null ? null : StrategySource.local), + isOpen = isOpen || + (strategyId ?? id) != null || + (strategyName ?? stratName) != null; + + final String? strategyId; + final String? strategyName; + final StrategySource? source; + final String? storageDirectory; + final bool isOpen; + @Deprecated('Use strategySaveStateProvider instead.') + final bool isSaved; + @Deprecated('Use strategyPageSessionProvider instead.') + final String? activePageId; + + @Deprecated('Use strategyId instead.') + String get id => strategyId ?? ''; + + @Deprecated('Use strategyName instead.') + String? get stratName => strategyName; + + StrategyState copyWith({ + String? strategyId, + String? strategyName, + StrategySource? source, + String? storageDirectory, + bool? isOpen, + String? id, + String? stratName, + bool? isSaved, + String? activePageId, + bool clearStrategyId = false, + bool clearStrategyName = false, + bool clearSource = false, + bool clearActivePageId = false, + }) { + return StrategyState( + strategyId: + clearStrategyId ? null : (strategyId ?? id ?? this.strategyId), + strategyName: clearStrategyName + ? null + : (strategyName ?? stratName ?? this.strategyName), + source: clearSource ? null : (source ?? this.source), + storageDirectory: storageDirectory ?? this.storageDirectory, + isOpen: isOpen ?? this.isOpen, + isSaved: isSaved ?? this.isSaved, + activePageId: + clearActivePageId ? null : (activePageId ?? this.activePageId), + ); + } +} diff --git a/lib/strategy/strategy_page_apply.dart b/lib/strategy/strategy_page_apply.dart new file mode 100644 index 00000000..5fc118f6 --- /dev/null +++ b/lib/strategy/strategy_page_apply.dart @@ -0,0 +1,121 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +Future applyStrategyEditorPageData( + Ref ref, + StrategyEditorPageData data, { + required String? themeProfileId, + required MapThemePalette? themeOverridePalette, + bool preserveHistory = false, + MapValue? mapOverride, +}) async { + ref.read(agentProvider.notifier).clearAll(); + ref.read(abilityProvider.notifier).clearAll(); + ref.read(drawingProvider.notifier).clearAll(); + ref.read(textProvider.notifier).clearAll(); + ref.read(placedImageProvider.notifier).clearAll(); + ref.read(utilityProvider.notifier).clearAll(); + ref.read(lineUpProvider.notifier).clearAll(); + if (!preserveHistory) { + ref.read(actionProvider.notifier).clearActionHistory(); + } + ref.read(agentProvider.notifier).fromHive(data.agents); + ref.read(abilityProvider.notifier).fromHive(data.abilities); + ref.read(drawingProvider.notifier).fromHive(data.drawings); + ref.read(textProvider.notifier).fromHive(data.texts); + ref.read(placedImageProvider.notifier).fromHive(data.images); + ref.read(utilityProvider.notifier).fromHive(data.utilities); + ref.read(lineUpProvider.notifier).fromHive(data.lineUpGraph); + ref + .read(mapProvider.notifier) + .fromHive(mapOverride ?? data.map, data.isAttack); + ref.read(strategySettingsProvider.notifier).fromHive(data.settings); + ref.read(strategyThemeProvider.notifier).fromStrategy( + profileId: themeProfileId, + overridePalette: themeOverridePalette, + ); + if (preserveHistory) { + ref.read(actionProvider.notifier).reconcileHistory(); + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + ref + .read(drawingProvider.notifier) + .rebuildAllPaths(CoordinateSystem.instance); + }); +} + +/// Brings the page on screen to the server's copy without reloading it. +/// Nothing the user is mid-way through is touched: a stroke being drawn, an +/// open text draft, a lineup being placed, or an item in [holding]. +/// +/// Returns the entities in [changed] (from `remoteChangesSinceHydration`) it +/// left as they are because the user is holding them; they apply once the +/// hold ends. +Set mergeRemoteStrategyEditorPageData( + Ref ref, + StrategyEditorPageData data, { + required Set changed, + required Set holding, + required String? themeProfileId, + required MapThemePalette? themeOverridePalette, + MapValue? mapOverride, +}) { + final heldBack = { + for (final key in changed) + if (key.kind == EntitySyncKeyKind.element && + holding.contains(key.entityId)) + key, + }; + bool keep(String id) => holding.contains(id); + + ref.read(agentProvider.notifier).mergeRemote(data.agents, keep); + ref.read(abilityProvider.notifier).mergeRemote(data.abilities, keep); + ref.read(drawingProvider.notifier).mergeRemote(data.drawings, keep); + ref.read(textProvider.notifier).mergeRemote(data.texts, keep); + ref.read(placedImageProvider.notifier).mergeRemote(data.images, keep); + ref.read(utilityProvider.notifier).mergeRemote(data.utilities, keep); + + final liveSync = ref.read(activePageLiveSyncProvider.notifier); + final (lineUpGraph, heldGroups) = mergeHeldLineups( + local: ref.read(lineUpProvider).graph, + remote: data.lineUpGraph.deepCopy(), + holding: holding, + groupOf: (id) => liveSync.lineupGroupOf(data.pageId, id), + ); + ref.read(lineUpProvider.notifier).mergeRemote(lineUpGraph); + heldBack.addAll({ + for (final key in changed) + if (key.kind == EntitySyncKeyKind.lineup && + heldGroups.contains(key.entityId)) + key, + }); + + ref + .read(mapProvider.notifier) + .fromHive(mapOverride ?? data.map, data.isAttack); + ref.read(strategySettingsProvider.notifier).fromHive(data.settings); + ref.read(strategyThemeProvider.notifier).fromStrategy( + profileId: themeProfileId, + overridePalette: themeOverridePalette, + ); + ref.read(actionProvider.notifier).reconcileHistory(); + return heldBack; +} diff --git a/lib/strategy/strategy_page_models.dart b/lib/strategy/strategy_page_models.dart new file mode 100644 index 00000000..8591281b --- /dev/null +++ b/lib/strategy/strategy_page_models.dart @@ -0,0 +1,37 @@ +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +enum StrategySource { local, cloud } + +class StrategyEditorPageData { + const StrategyEditorPageData({ + required this.pageId, + required this.pageName, + required this.isAttack, + required this.map, + required this.settings, + required this.agents, + required this.abilities, + required this.drawings, + required this.texts, + required this.images, + required this.utilities, + required this.lineUpGraph, + }); + + final String pageId; + final String pageName; + final bool isAttack; + final MapValue map; + final StrategySettings settings; + final List agents; + final List abilities; + final List drawings; + final List texts; + final List images; + final List utilities; + final LineUpGraph lineUpGraph; +} diff --git a/lib/strategy/strategy_page_source.dart b/lib/strategy/strategy_page_source.dart new file mode 100644 index 00000000..3cb26005 --- /dev/null +++ b/lib/strategy/strategy_page_source.dart @@ -0,0 +1,499 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/strategy_capabilities.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:uuid/uuid.dart'; + +abstract class StrategyPageSource { + RemoteEditorSnapshot? get loadedRemoteSnapshot; + Future> listPageIds(); + Future loadPage(String pageId); + Future flushCurrentPage(); +} + +class LocalStrategyPageSource implements StrategyPageSource { + LocalStrategyPageSource( + this.ref, { + required this.strategyId, + required this.activePageId, + }); + + final Ref ref; + final String strategyId; + final String? Function() activePageId; + + @override + RemoteEditorSnapshot? get loadedRemoteSnapshot => null; + + @override + Future> listPageIds() async { + final strategy = Hive.box(HiveBoxNames.strategiesBox).get( + strategyId, + ); + if (strategy == null) { + return const []; + } + final pages = [...strategy.pages] + ..sortBySortIndex((item) => item.sortIndex); + return pages.map((page) => page.id).toList(growable: false); + } + + @override + Future loadPage(String pageId) async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final current = box.get(strategyId); + if (current == null) { + throw StateError('Strategy $strategyId not found.'); + } + + final migrated = StrategyMigrator.migrateToCurrentVersion(current); + if (!identical(current, migrated)) { + await box.put(migrated.id, migrated); + } + + final orderedPages = [...migrated.pages] + ..sortBySortIndex((item) => item.sortIndex); + final page = orderedPages.firstWhere( + (entry) => entry.id == pageId, + orElse: () => orderedPages.first, + ); + + return StrategyEditorPageData( + pageId: page.id, + pageName: page.name, + isAttack: page.isAttack, + map: migrated.mapData, + settings: page.settings, + agents: page.agentData, + abilities: page.abilityData, + drawings: page.drawingData, + texts: page.textData, + images: page.imageData, + utilities: page.utilityData, + lineUpGraph: page.lineUpGraph, + ); + } + + @override + Future flushCurrentPage() async { + final box = Hive.box(HiveBoxNames.strategiesBox); + final strategy = box.get(strategyId); + if (strategy == null || strategy.pages.isEmpty) { + return; + } + + final pageId = activePageId() ?? strategy.pages.first.id; + final index = strategy.pages.indexWhere((page) => page.id == pageId); + if (index < 0) { + return; + } + + final updatedPage = strategy.pages[index].copyWith( + drawingData: ref.read(drawingProvider).elements, + agentData: ref.read(agentProvider), + abilityData: ref.read(abilityProvider), + textData: ref.read(textProvider.notifier).snapshotForPersistence(), + imageData: ref.read(placedImageProvider).images, + utilityData: ref.read(utilityProvider), + isAttack: ref.read(mapProvider).isAttack, + settings: ref.read(strategySettingsProvider), + lineUpGraph: ref.read(lineUpProvider).graph, + ); + + final strategyTheme = ref.read(strategyThemeProvider); + final updatedPages = [...strategy.pages]..[index] = updatedPage; + final updated = strategy.copyWith( + pages: updatedPages, + mapData: ref.read(mapProvider).currentMap, + themeProfileId: strategyTheme.profileId, + clearThemeProfileId: strategyTheme.profileId == null, + themeOverridePalette: strategyTheme.overridePalette, + clearThemeOverridePalette: strategyTheme.overridePalette == null, + lastEdited: DateTime.now(), + ); + await box.put(updated.id, updated); + } +} + +class CloudStrategyPageSource implements StrategyPageSource { + CloudStrategyPageSource( + this.ref, { + required this.strategyId, + required this.activePageId, + }); + + final Ref ref; + final String strategyId; + final String? Function() activePageId; + RemoteEditorSnapshot? _loadedRemoteSnapshot; + + @override + RemoteEditorSnapshot? get loadedRemoteSnapshot => _loadedRemoteSnapshot; + + RemoteEditorSnapshot get _snapshot { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null) { + throw StateError('Remote snapshot unavailable for $strategyId.'); + } + return snapshot; + } + + @override + Future> listPageIds() async { + final pages = [..._snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + return pages.map((page) => page.publicId).toList(growable: false); + } + + @override + Future loadPage(String pageId) => _loadPage(pageId); + + Future loadAuthoritativePage( + String pageId, { + required Set discardedEntities, + }) => + _loadPage(pageId, excludedOverlays: discardedEntities); + + Future _loadPage( + String pageId, { + Set excludedOverlays = const {}, + }) async { + if (ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .publicId != + pageId) { + await ref + .read(remoteEditorSnapshotProvider.notifier) + .setActivePage(pageId); + } + final snapshot = _snapshot; + _loadedRemoteSnapshot = snapshot; + final pages = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + final page = pages.firstWhere( + (entry) => entry.publicId == pageId, + orElse: () => pages.first, + ); + + ref.read(activePageLiveSyncProvider.notifier).dropSatisfiedOverlays( + page.publicId, + ); + final projected = + ref.read(activePageLiveSyncProvider.notifier).projectPageState( + strategyPublicId: strategyId, + pageId: page.publicId, + excludedOverlays: excludedOverlays, + ); + if (projected != null && + (page.publicId == activePageId() || + ref + .read(activePageLiveSyncProvider.notifier) + .hasOverlayForPage(page.publicId))) { + return _hydrateProjectedPage(snapshot, page, projected); + } + + final pageSnapshot = snapshot.activePage; + if (pageSnapshot == null || pageSnapshot.page.publicId != page.publicId) { + throw StateError( + 'Remote page snapshot unavailable for ${page.publicId}.'); + } + final elements = pageSnapshot.elements; + final lineups = pageSnapshot.lineups; + + final agents = []; + final abilities = []; + final drawings = []; + final texts = []; + final images = []; + final utilities = []; + + for (final element in elements) { + if (element.deleted) { + continue; + } + final payload = element.decodedPayload(); + try { + switch (element.elementType) { + case 'agent': + agents.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilities.add(PlacedAbility.fromJson(payload)); + break; + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) { + drawings.add(decoded.first); + } + break; + case 'text': + texts.add(PlacedText.fromJson(payload)); + break; + case 'image': + final hydrated = PlacedImage.fromJson(payload); + final remoteAsset = snapshot.assetsById[hydrated.id]; + images.add(hydrated); + if (remoteAsset != null) { + ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyId, + asset: remoteAsset, + ); + } + break; + case 'utility': + utilities.add(PlacedUtility.fromJson(payload)); + break; + } + } catch (_) { + // Ignore malformed payloads during hydration. + } + } + + final cloudLineups = lineUpGraphFromCloudRows([ + for (final lineup in lineups) + if (!lineup.deleted) CloudLineupRow.remote(lineup), + ]); + ref + .read(activePageLiveSyncProvider.notifier) + .noteLineupGroups(page.publicId, cloudLineups.groupOf); + + final mapValue = Maps.mapNames.entries.firstWhere( + (entry) => entry.value == snapshot.header.mapData, + orElse: () => const MapEntry(MapValue.ascent, 'ascent'), + ); + + StrategySettings pageSettings = StrategySettings(); + final settingsPayload = pageSnapshot.content.settings; + if (settingsPayload != null && settingsPayload.isNotEmpty) { + try { + pageSettings = StrategySettings.fromJson(settingsPayload); + } catch (_) { + pageSettings = StrategySettings(); + } + } + + return StrategyEditorPageData( + pageId: page.publicId, + pageName: page.name, + isAttack: page.isAttack, + map: mapValue.key, + settings: pageSettings, + agents: agents, + abilities: abilities, + drawings: drawings, + texts: texts, + images: images, + utilities: utilities, + lineUpGraph: cloudLineups.graph, + ); + } + + @override + Future flushCurrentPage() async { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || + snapshot.header.publicId != strategyId || + !StrategyCapabilities.fromCloudRole(snapshot.header.role) + .canEditPages) { + return; + } + final pageId = activePageId(); + if (pageId == null) { + return; + } + + await _syncStrategyMetadata(); + + final desiredOpsByEntityKey = + ref.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: strategyId, + pageId: pageId, + ); + if (desiredOpsByEntityKey == null) { + return; + } + await ref.read(strategyOpQueueProvider.notifier).syncDesiredOpsForPage( + pageId: pageId, + desiredOpsByEntityKey: desiredOpsByEntityKey, + flushImmediately: false, + ); + } + + Future _syncStrategyMetadata() async { + final snapshot = ref.read(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null) { + return; + } + + final currentMapData = Maps.mapNames[ref.read(mapProvider).currentMap]; + if (currentMapData == null) { + return; + } + + final strategyTheme = ref.read(strategyThemeProvider); + final desiredThemeOverride = strategyTheme.overridePalette?.toJson(); + final header = snapshot.header; + + final mapMatches = header.mapData == currentMapData; + final themeProfileMatches = + header.themeProfileId == strategyTheme.profileId; + final themeOverrideMatches = + cloudJsonEquivalent(header.themeOverridePalette, desiredThemeOverride); + + if (mapMatches && themeProfileMatches && themeOverrideMatches) { + await ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + entityKey: const EntitySyncKey.strategy(), + desiredOp: null, + flushImmediately: false, + ); + return; + } + + final payload = { + 'mapData': currentMapData, + if (strategyTheme.profileId != null) + 'themeProfileId': strategyTheme.profileId + else + 'clearThemeProfileId': true, + if (desiredThemeOverride != null) + 'themeOverridePalette': desiredThemeOverride + else + 'clearThemeOverridePalette': true, + }; + + await ref.read(strategyOpQueueProvider.notifier).syncDesiredGenericOp( + entityKey: const EntitySyncKey.strategy(), + desiredOp: StrategyPatchOp( + opId: const Uuid().v4(), + payload: payload, + expectedStrategyRevision: header.revision, + ), + flushImmediately: false, + ); + } + + StrategyEditorPageData _hydrateProjectedPage( + RemoteEditorSnapshot snapshot, + RemotePage page, + ActivePageProjectedState projected, + ) { + final agents = []; + final abilities = []; + final drawings = []; + final texts = []; + final images = []; + final utilities = []; + + for (final element in projected.elements) { + final payload = cloudPayloadData(element.payload); + try { + switch (element.elementType) { + case 'agent': + agents.add(PlacedAgentNode.fromJson(payload)); + break; + case 'ability': + abilities.add(PlacedAbility.fromJson(payload)); + break; + case 'drawing': + final decoded = DrawingProvider.fromJson(jsonEncode([payload])); + if (decoded.isNotEmpty) { + drawings.add(decoded.first); + } + break; + case 'text': + texts.add(PlacedText.fromJson(payload)); + break; + case 'image': + final hydrated = PlacedImage.fromJson(payload); + final remoteAsset = snapshot.assetsById[hydrated.id]; + images.add(hydrated); + if (remoteAsset != null) { + ref.read(cloudMediaCacheProvider.notifier).ensureAssetCached( + strategyId: strategyId, + strategyPublicId: strategyId, + asset: remoteAsset, + ); + } + break; + case 'utility': + utilities.add(PlacedUtility.fromJson(payload)); + break; + } + } catch (_) { + // Ignore malformed payloads during hydration. + } + } + + final cloudLineups = lineUpGraphFromCloudRows([ + for (final lineup in projected.lineups) + CloudLineupRow(publicId: lineup.publicId, payload: lineup.payload), + ]); + ref + .read(activePageLiveSyncProvider.notifier) + .noteLineupGroups(page.publicId, cloudLineups.groupOf); + + final mapValue = Maps.mapNames.entries.firstWhere( + (entry) => entry.value == snapshot.header.mapData, + orElse: () => const MapEntry(MapValue.ascent, 'ascent'), + ); + + final settings = _parsePageSettings(projected.settingsPayload); + + return StrategyEditorPageData( + pageId: projected.pageId, + pageName: page.name, + isAttack: projected.isAttack, + map: mapValue.key, + settings: settings, + agents: agents, + abilities: abilities, + drawings: drawings, + texts: texts, + images: images, + utilities: utilities, + lineUpGraph: cloudLineups.graph, + ); + } + + StrategySettings _parsePageSettings(Map? settingsPayload) { + if (settingsPayload == null) { + return StrategySettings(); + } + try { + return StrategySettings.fromJson(settingsPayload); + } catch (_) { + return StrategySettings(); + } + } +} diff --git a/lib/strategy_view.dart b/lib/strategy_view.dart index 557d21a1..e16c8eac 100644 --- a/lib/strategy_view.dart +++ b/lib/strategy_view.dart @@ -4,24 +4,33 @@ import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart' + show isShareLinkRevokedError; import 'package:icarus/const/custom_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/routes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/interactive_map.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/open_cloud_strategy_store.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:icarus/sidebar.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/delete_capture.dart'; -import 'package:icarus/widgets/demo_tag.dart'; import 'package:icarus/widgets/strategy_view_skeleton.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/strategy_quick_switcher.dart'; import 'package:icarus/widgets/map_selector.dart'; import 'package:icarus/widgets/pages_bar.dart'; import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -33,18 +42,21 @@ class StrategyView extends ConsumerStatefulWidget { super.key, this.initialStrategyId, this.initialStrategyName, + this.initialStrategySource = StrategySource.local, this.initialMapValue, this.initialIsAttack = true, }); final String? initialStrategyId; final String? initialStrategyName; + final StrategySource initialStrategySource; final MapValue? initialMapValue; final bool initialIsAttack; static PageRoute route({ String? initialStrategyId, String? initialStrategyName, + StrategySource initialStrategySource = StrategySource.local, MapValue? initialMapValue, bool initialIsAttack = true, }) { @@ -55,6 +67,7 @@ class StrategyView extends ConsumerStatefulWidget { pageBuilder: (context, animation, _) => StrategyView( initialStrategyId: initialStrategyId, initialStrategyName: initialStrategyName, + initialStrategySource: initialStrategySource, initialMapValue: initialMapValue, initialIsAttack: initialIsAttack, ), @@ -73,6 +86,25 @@ class StrategyView extends ConsumerStatefulWidget { ); } + /// The editor for the named route [Routes.strategyView], which is what a + /// web page reload inside the editor restarts on. The route name carries no + /// strategy, so this reopens the cloud strategy the tab last had open + /// ([openCloudStrategyId]). With nothing to reopen it returns an empty + /// editor only where a local library exists; elsewhere (the web beta) it + /// returns null, and the navigator falls back to the library. + static PageRoute? restoredRoute({ + required String? openCloudStrategyId, + required bool allowsLocalLibrary, + }) { + if (openCloudStrategyId != null) { + return route( + initialStrategyId: openCloudStrategyId, + initialStrategySource: StrategySource.cloud, + ); + } + return allowsLocalLibrary ? route() : null; + } + @override ConsumerState createState() => _StrategyViewState(); } @@ -113,6 +145,7 @@ class _StrategyViewState extends ConsumerState Future _loadInitialStrategy() async { final strategyId = widget.initialStrategyId; + final source = widget.initialStrategySource; if (strategyId == null) { if (mounted) { setState(() => _isInitialLoadPending = false); @@ -121,9 +154,25 @@ class _StrategyViewState extends ConsumerState } try { - await ref.read(strategyProvider.notifier).loadFromHive(strategyId); + switch (source) { + case StrategySource.local: + await ref.read(strategyProvider.notifier).loadFromHive(strategyId); + case StrategySource.cloud: + final viewingThroughLink = + ref.read(shareLinkViewProvider)?.strategyPublicId == strategyId; + if (!viewingThroughLink && !await _waitForCloudWorkspace()) { + // Signed out, or the cloud is unreachable: nothing failed to + // load, and the library says which, so go back without an error. + if (mounted) Navigator.maybePop(context); + return; + } + await ref.read(strategyProvider.notifier).openCloudStrategy( + strategyId, + ); + } final loadedStrategy = ref.read(strategyProvider); - if (loadedStrategy.id != strategyId || loadedStrategy.stratName == null) { + if (loadedStrategy.strategyId != strategyId || + loadedStrategy.strategyName == null) { throw StateError('Strategy "$strategyId" was not found.'); } _hasInitialLoadCompleted = true; @@ -148,6 +197,21 @@ class _StrategyViewState extends ConsumerState } } + /// Resolves [cloudWorkspaceOutcomeProvider] once it settles. After a page + /// reload the editor can open before sign-in is restored, so a cloud + /// strategy waits here rather than failing on an unready backend. + Future _waitForCloudWorkspace() { + final settled = Completer(); + final subscription = ref.listenManual( + cloudWorkspaceOutcomeProvider, + (_, outcome) { + if (outcome != null && !settled.isCompleted) settled.complete(outcome); + }, + fireImmediately: true, + ); + return settled.future.whenComplete(subscription.close); + } + Future _leaveToLibrary() async { await guardUnsavedStrategyExit( context: context, @@ -161,23 +225,41 @@ class _StrategyViewState extends ConsumerState .read(agentFilterProvider.notifier) .updateFilterState(FilterState.all); ref.read(deleteMenuProvider.notifier).requestClose(); - await ref.read(strategyProvider.notifier).clearCurrentStrategy(); if (mounted) { Navigator.pop(context); } + await ref.read(strategyProvider.notifier).clearCurrentStrategy(); }, ); } @override Widget build(BuildContext context) { + ref.watch(openCloudStrategyRecorderProvider); + // A reader who is here only through a share link loses the strategy the + // moment its owner disables the link. + ref.listen(remoteEditorSnapshotProvider, (_, next) { + final error = next.error; + if (error == null || + !isShareLinkRevokedError(error) || + ref.read(shareLinkViewProvider) == null) { + return; + } + ref.read(shareLinkControllerProvider.notifier).forgetViewedLink(); + Settings.showToast( + message: 'This share link was disabled by its owner.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + unawaited(_leaveToLibrary()); + }); final strategyState = ref.watch(strategyProvider); + final viewOnly = ref.watch(isViewOnlyStrategyProvider); final initialStrategyId = widget.initialStrategyId; final showSkeleton = _isInitialLoadPending || (!_hasInitialLoadCompleted && initialStrategyId != null && - (strategyState.stratName == null || - strategyState.id != initialStrategyId)); + (!strategyState.isOpen || + strategyState.strategyId != initialStrategyId)); if (showSkeleton) { return Scaffold( @@ -216,14 +298,12 @@ class _StrategyViewState extends ConsumerState ), ), const IcarusWordmark(), - if (kIsWeb) - const Padding( - padding: EdgeInsets.symmetric(horizontal: 8.0), - child: DemoTag(), - ), const Expanded( child: WindowDragArea(child: SizedBox.expand()), ), + // Who else has this strategy open. Beside the strategy's + // own name, where you look to see what you're in. + const StrategyPresenceAvatars(), const _DiscordLink(), const SizedBox(width: 10), ], @@ -234,16 +314,18 @@ class _StrategyViewState extends ConsumerState ), // The canvas runs right up to the strip; each floating panel keeps // its own 8px of air so no bare band shows between the two. - const Expanded( + Expanded( child: Stack( clipBehavior: Clip.none, children: [ - Positioned.fill(child: DeleteCapture()), - Align( + const Positioned.fill( + child: StrategyEditBoundary(child: DeleteCapture()), + ), + const Align( alignment: Alignment.centerLeft, child: RepaintBoundary(child: InteractiveMap()), ), - Align( + const Align( alignment: Alignment.topLeft, child: Padding( padding: EdgeInsets.all(8), @@ -251,21 +333,33 @@ class _StrategyViewState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - MapSelector(), + StrategyEditBoundary( + disabledOpacity: 0.55, + child: MapSelector(), + ), SizedBox(height: 8), EditorToolbar(), ], ), ), ), - Align( + const Align( alignment: Alignment.bottomLeft, child: Padding( padding: EdgeInsets.all(8.0), child: PagesBar(), ), ), - Align(alignment: Alignment.centerRight, child: SideBarUI()), + // Tools and agents only place things; a view-only reader + // gets the map in their place. + if (!viewOnly) + const Align( + alignment: Alignment.centerRight, + child: StrategyEditBoundary( + disabledOpacity: 0.55, + child: SideBarUI(), + ), + ), ], ), ), diff --git a/lib/widgets/account_avatar.dart b/lib/widgets/account_avatar.dart new file mode 100644 index 00000000..4d6f0cbd --- /dev/null +++ b/lib/widgets/account_avatar.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class AccountAvatar extends StatelessWidget { + const AccountAvatar({ + super.key, + required this.radius, + required this.backgroundColor, + required this.fallback, + this.avatarUrl, + }); + + final double radius; + final Color backgroundColor; + final Widget fallback; + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final image = avatarUrl == null ? null : NetworkImage(avatarUrl!); + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + foregroundImage: image, + onForegroundImageError: image == null ? null : (_, __) {}, + child: fallback, + ); + } +} diff --git a/lib/widgets/client_upgrade_button.dart b/lib/widgets/client_upgrade_button.dart new file mode 100644 index 00000000..c6cf7cb1 --- /dev/null +++ b/lib/widgets/client_upgrade_button.dart @@ -0,0 +1,143 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/services/browser_url.dart'; +import 'package:icarus/services/unsaved_strategy_guard.dart'; +import 'package:icarus/widgets/strip_status_icons.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// What the user is told while the server refuses this build, and the one +/// action that can help, if there is one. The web reloads into the build +/// being served. Desktop installs the waiting update (the one the strip's +/// update icon opens); while none is known it says so and offers to check +/// again, which also asks the server whether it accepts this build again. +class ClientUpgradeNotice extends StatelessWidget { + const ClientUpgradeNotice({ + super.key, + required this.builder, + this.buttonSize, + this.isWeb = kIsWeb, + this.onReload = reloadBrowserPage, + }); + + final Widget Function(BuildContext context, String message, Widget? action) + builder; + final ShadButtonSize? buttonSize; + final bool isWeb; + + /// Loads the page again; replaced in tests. + final VoidCallback onReload; + + @override + Widget build(BuildContext context) { + if (isWeb) { + return builder( + context, + clientUpgradeRequiredMessage(isWeb: true), + _ReloadButton(size: buttonSize, onReload: onReload), + ); + } + return WaitingUpdateBuilder( + builder: (context, openUpdate, isChecking) { + if (openUpdate != null) { + return builder( + context, + clientUpgradeRequiredMessage(isWeb: false), + _button( + icon: LucideIcons.download, + label: 'Update', + size: buttonSize, + onPressed: openUpdate, + ), + ); + } + return builder( + context, + clientUpgradeRequiredMessage( + isWeb: false, + update: isChecking + ? ClientUpdateAvailability.checking + : ClientUpdateAvailability.unavailable, + ), + isChecking ? null : _CheckAgainButton(size: buttonSize), + ); + }, + ); + } +} + +/// Reloading throws the page away, as leaving the strategy does, so it goes +/// through the same guard as Home: it saves what it can, and asks before +/// leaving anything it could not confirm is kept on this device. +class _ReloadButton extends ConsumerStatefulWidget { + const _ReloadButton({required this.size, required this.onReload}); + + final ShadButtonSize? size; + final VoidCallback onReload; + + @override + ConsumerState<_ReloadButton> createState() => _ReloadButtonState(); +} + +class _ReloadButtonState extends ConsumerState<_ReloadButton> { + bool _leaving = false; + + Future _reload() async { + setState(() => _leaving = true); + await guardUnsavedStrategyExit( + context: context, + ref: ref, + onContinue: () async => widget.onReload(), + source: 'client_upgrade.reload', + ); + if (mounted) setState(() => _leaving = false); + } + + @override + Widget build(BuildContext context) { + return _button( + icon: LucideIcons.refreshCw, + label: 'Reload', + size: widget.size, + onPressed: _leaving ? null : _reload, + ); + } +} + +class _CheckAgainButton extends ConsumerWidget { + const _CheckAgainButton({required this.size}); + + final ShadButtonSize? size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _button( + icon: LucideIcons.refreshCw, + label: 'Check again', + size: size, + onPressed: () { + ref.invalidate(appUpdateStatusProvider); + ref.read(clientUpgradeRequiredProvider.notifier).recheck(); + }, + ); + } +} + +Widget _button({ + required IconData icon, + required String label, + required ShadButtonSize? size, + required VoidCallback? onPressed, +}) { + return ShadButton( + key: const ValueKey('client-upgrade-button'), + size: size, + expands: false, + leading: Icon(icon, size: 16), + onPressed: onPressed, + child: Text(label), + ); +} diff --git a/lib/widgets/cloud_outbox_summary_banner.dart b/lib/widgets/cloud_outbox_summary_banner.dart new file mode 100644 index 00000000..8a4af032 --- /dev/null +++ b/lib/widgets/cloud_outbox_summary_banner.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy_view.dart'; +import 'package:icarus/widgets/client_upgrade_button.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class CloudOutboxSummaryBanner extends ConsumerWidget { + const CloudOutboxSummaryBanner({super.key, this.onOpenStrategy}); + + final ValueChanged? onOpenStrategy; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (ref.watch(libraryWorkspaceProvider) != LibraryWorkspace.cloud) { + return const SizedBox.shrink(); + } + final opQueue = ref.watch(strategyOpQueueProvider); + final mediaQueue = ref.watch(cloudMediaUploadQueueProvider); + final auth = ref.watch(authProvider); + final strategyNames = ref.watch(cloudStrategyNamesProvider); + final connected = ref.watch(convexConnectionProvider).valueOrNull ?? true; + final strategyIds = { + ...opQueue.accountOutbox.strategies.keys, + for (final job in mediaQueue.jobs) job.strategyPublicId, + }; + final workCount = opQueue.accountOutbox.workCount + mediaQueue.jobs.length; + final failedMediaByStrategy = {}; + for (final job in mediaQueue.jobs.where((job) => job.isFailed)) { + failedMediaByStrategy.update( + job.strategyPublicId, + (count) => count + 1, + ifAbsent: () => 1, + ); + } + final attentionIds = { + for (final summary in opQueue.accountOutbox.strategies.values) + if (summary.needsAttention) summary.strategyPublicId, + ...failedMediaByStrategy.keys, + }; + // Unsent changes to strategies the server no longer has: never sent, + // only discarded (each is a separate action below). + final deletedStrategies = opQueue.accountOutbox.deletedStrategies; + final deletedChangeCount = + deletedStrategies.values.fold(0, (total, count) => total + count); + final hasDurabilityProblem = !opQueue.outboxIsReliable || + !mediaQueue.outboxIsReliable || + opQueue.loadIssues.isNotEmpty || + mediaQueue.loadIssues.isNotEmpty; + final authBlocked = workCount > 0 && + (auth.hasActiveAuthIncident || !auth.isConvexUserReady); + // The server refuses this build, so none of the work is being sent. Work + // that may not be on this device still outranks it. + final heldForUpgrade = workCount > 0 && + !hasDurabilityProblem && + ref.watch(clientUpgradeRequiredProvider); + if (workCount == 0 && !hasDurabilityProblem && deletedStrategies.isEmpty) { + return const SizedBox.shrink(); + } + if (heldForUpgrade) { + return ClientUpgradeNotice( + buttonSize: ShadButtonSize.sm, + builder: (context, message, action) => _card( + context, + needsAttention: true, + connected: connected, + title: 'Cloud work is waiting for a newer Icarus', + detail: '$workCount saved ' + '${workCount == 1 ? 'change is' : 'changes are'} kept on this ' + 'device. $message', + actions: [if (action != null) action], + ), + ); + } + + final needsAttention = hasDurabilityProblem || + authBlocked || + attentionIds.isNotEmpty || + deletedStrategies.isNotEmpty; + final title = needsAttention + ? 'Cloud work needs attention' + : connected + ? 'Syncing cloud work' + : 'Working offline'; + final detail = hasDurabilityProblem + ? 'Icarus cannot read some saved cloud work on this device. Stay ' + 'signed in and review it.' + : authBlocked + ? 'Reconnect this account before Icarus can send its saved work.' + : workCount == 0 && deletedStrategies.isNotEmpty + ? '$deletedChangeCount unsent ' + '${deletedChangeCount == 1 ? 'change' : 'changes'} to a ' + 'strategy that was deleted cannot be saved. Discard ' + '${deletedChangeCount == 1 ? 'it' : 'them'} to clear this.' + : needsAttention + ? '$workCount saved ${workCount == 1 ? 'change needs' : 'changes need'} ' + 'review across ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : connected + ? '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'being sent from ${strategyIds.length} ' + '${strategyIds.length == 1 ? 'strategy' : 'strategies'}.' + : '$workCount saved ${workCount == 1 ? 'change is' : 'changes are'} ' + 'waiting on this device and will resume when the ' + 'connection returns.'; + return _card( + context, + needsAttention: needsAttention, + connected: connected, + title: title, + detail: detail, + actions: [ + for (final strategyId in attentionIds) + ShadButton.secondary( + size: ShadButtonSize.sm, + onPressed: () => _openStrategy(context, strategyId), + child: Flexible( + child: Text( + _attentionLabel( + strategyId, + strategyNames[strategyId], + opQueue.accountOutbox.strategies[strategyId]?.reason, + failedMediaByStrategy[strategyId] ?? 0, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + for (final strategyId in deletedStrategies.keys) + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: () => _discardDeleted(ref, strategyId), + child: Flexible( + child: Text( + 'Discard changes to ' + '${strategyNames[strategyId]?.trim().isNotEmpty == true ? strategyNames[strategyId]!.trim() : 'the deleted strategy'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ); + } + + /// A floating status card, not a banner: it sits over the library's + /// corner like a floating menu and never moves the grid. The parent + /// positions it. + Widget _card( + BuildContext context, { + required bool needsAttention, + required bool connected, + required String title, + required String detail, + required List actions, + }) { + final theme = ShadTheme.of(context); + return Container( + key: const ValueKey('cloud-outbox-summary'), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.popover, + border: Border.all(color: Settings.tacticalVioletTheme.border), + borderRadius: BorderRadius.circular(12), + boxShadow: const [Settings.floatingMenuShadow], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + needsAttention + ? LucideIcons.circleAlert + : connected + ? LucideIcons.cloudUpload + : LucideIcons.cloudOff, + size: 16, + color: needsAttention + ? theme.colorScheme.destructive + : theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + const SizedBox(height: 2), + Text( + detail, + style: TextStyle( + fontSize: 12, + height: 1.35, + color: theme.colorScheme.mutedForeground, + ), + ), + if (actions.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: actions), + ], + ], + ), + ), + ], + ), + ); + } + + Future _discardDeleted(WidgetRef ref, String strategyId) async { + await ref + .read(strategyOpQueueProvider.notifier) + .discardDeletedStrategy(strategyId); + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .clearJobsForStrategy(strategyId); + } + + void _openStrategy(BuildContext context, String strategyId) { + final callback = onOpenStrategy; + if (callback != null) { + callback(strategyId); + return; + } + Navigator.of(context).push( + StrategyView.route( + initialStrategyId: strategyId, + initialStrategySource: StrategySource.cloud, + ), + ); + } + + String _attentionLabel( + String id, + String? strategyName, + String? reason, + int failedMediaCount, + ) { + final label = strategyName?.trim().isNotEmpty == true + ? strategyName!.trim() + : 'A cloud strategy'; + if (reason != null && reason.isNotEmpty) { + return '$label: review sync'; + } + if (failedMediaCount > 0) { + return '$label: $failedMediaCount ' + '${failedMediaCount == 1 ? 'image failed' : 'images failed'}'; + } + return '$label: review'; + } +} diff --git a/lib/widgets/cloud_sync_button.dart b/lib/widgets/cloud_sync_button.dart new file mode 100644 index 00000000..b6df1dd7 --- /dev/null +++ b/lib/widgets/cloud_sync_button.dart @@ -0,0 +1,759 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; +import 'package:icarus/providers/collab/lineup_conflicts_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/client_upgrade_button.dart'; +import 'package:icarus/widgets/dialogs/deleted_page_dialog.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +const _glyphSwitchDuration = Duration(milliseconds: 150); +const _conflictToastGap = Duration(seconds: 5); + +enum _SyncStatus { synced, editing, syncing, offline, attention } + +/// The save button of a cloud strategy. Its glyph is the sync state, so the +/// promise that work is on the server has a face without a text chip: +/// synced, editing, syncing, offline, or needs attention. +/// +/// Pressing it saves now. When sync needs attention the press opens a popover +/// that explains what happened and offers recovery instead. Conflicts (the +/// server rejected an edit while the local intent was kept) surface as a toast. +/// Unsaved work on a page a teammate deleted asks the user what to do with it. +/// +/// Renders nothing for local strategies; [AutoSaveButton] covers those. +class CloudSyncButton extends ConsumerStatefulWidget { + const CloudSyncButton({super.key, required this.style}); + + final EditorToolbarButtonStyle style; + + @override + ConsumerState createState() => _CloudSyncButtonState(); +} + +class _CloudSyncButtonState extends ConsumerState { + final ShadPopoverController _popoverController = ShadPopoverController(); + DateTime? _lastConflictToast; + Timer? _pendingConflictToast; + bool _isResolving = false; + String? _resolutionError; + bool _showingDeletedPage = false; + + @override + void initState() { + super.initState(); + // The editor may have been rebuilt while a deleted page was waiting. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final page = ref.read(strategyPageSessionProvider).deletedPage; + if (page != null) _showDeletedPage(page); + }); + } + + Future _showDeletedPage(DeletedPage page) async { + if (_showingDeletedPage) return; + _showingDeletedPage = true; + try { + await DeletedPageDialog.show(context, page); + } finally { + _showingDeletedPage = false; + } + } + + @override + void dispose() { + _pendingConflictToast?.cancel(); + _popoverController.dispose(); + super.dispose(); + } + + void _onConflicts(int previousCount, int count) { + if (count <= previousCount) { + return; + } + final now = DateTime.now(); + final sinceLastToast = _lastConflictToast == null + ? _conflictToastGap + : now.difference(_lastConflictToast!); + if (sinceLastToast >= _conflictToastGap) { + _showConflictToast(); + } else { + // Throttled: hold the conflicts and notify once the window expires so + // no rebase goes completely unannounced. + _pendingConflictToast ??= Timer(_conflictToastGap - sinceLastToast, () { + _pendingConflictToast = null; + if (!mounted) { + return; + } + if (ref.read(strategyConflictProvider).isNotEmpty) { + _showConflictToast(); + } + }); + } + } + + void _showConflictToast() { + _lastConflictToast = DateTime.now(); + final specificReason = ref + .read(strategyConflictProvider) + .map((conflict) => conflict.message) + .whereType() + .where(isSpecificAttentionReason) + .firstOrNull; + Settings.showToast( + message: specificReason != null + ? friendlyCloudSyncError(specificReason) + : 'Another edit reached the cloud first. Your version is still on ' + 'this device and needs attention.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + ref.read(strategyConflictProvider.notifier).clearAll(); + } + + Future _handlePressed(_SyncStatus status) async { + if (status == _SyncStatus.attention) { + _popoverController.toggle(); + return; + } + await saveStrategyNow(context, ref); + } + + Future _retry() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); + _popoverController.hide(); + try { + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + final opQueue = ref.read(strategyOpQueueProvider.notifier); + await opQueue.retryPaused(flushImmediately: false); + await opQueue.retryRejected(flushImmediately: false); + await opQueue.flushNow(); + opQueue.clearStaleError(); + } finally { + if (mounted) setState(() => _isResolving = false); + } + } + + Future _useCloudVersions() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); + String? resolutionError; + try { + final resolved = await ref + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + if (resolved) { + _popoverController.hide(); + } else { + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } + } catch (error, stackTrace) { + log( + 'Failed to use the cloud version: $error', + name: 'cloud_conflict_resolution', + error: error, + stackTrace: stackTrace, + ); + resolutionError = 'Could not load the cloud version. ' + 'Your saved version was not changed.'; + } finally { + if (mounted) { + setState(() { + _isResolving = false; + _resolutionError = resolutionError; + }); + } + } + } + + Future _keepBoth() async { + if (_isResolving) return; + setState(() { + _isResolving = true; + _resolutionError = null; + }); + String? resolutionError; + try { + switch (await ref + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected()) { + case KeepBothOutcome.kept: + _popoverController.hide(); + case KeepBothOutcome.unchanged: + resolutionError = 'Could not load the cloud version. ' + 'Nothing was changed.'; + case KeepBothOutcome.copiesOnly: + resolutionError = 'Your version was added as a copy, but the ' + 'conflict is still open.'; + } + } catch (error, stackTrace) { + log( + 'Failed to keep both versions: $error', + name: 'cloud_conflict_resolution', + error: error, + stackTrace: stackTrace, + ); + resolutionError = 'Could not keep both versions. ' + 'Your version is still saved on this device.'; + } finally { + if (mounted) { + setState(() { + _isResolving = false; + _resolutionError = resolutionError; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final source = ref.watch(strategyProvider.select((state) => state.source)); + ref.listen(strategyConflictProvider, (previous, next) { + _onConflicts(previous?.length ?? 0, next.length); + }); + ref.listen(strategyPageSessionProvider.select((state) => state.deletedPage), + (previous, next) { + if (next != null && previous == null) _showDeletedPage(next); + }); + + if (source != StrategySource.cloud) { + return const SizedBox.shrink(); + } + + final saveState = ref.watch(strategySaveStateProvider); + // Work that may not be on this device outranks the refusal: its own + // recovery shows, and nothing offers to throw the session away. + final upgradeRequired = ref.watch(clientUpgradeRequiredProvider) && + !ref.watch(cloudWorkDurabilityUncertainProvider); + final opQueueState = ref.watch(strategyOpQueueProvider); + final mediaQueueState = ref.watch(cloudMediaUploadQueueProvider); + final activeStrategyId = ref.watch( + strategyProvider.select((state) => state.strategyId), + ); + final hasOtherStrategyWork = opQueueState.accountOutbox.strategies.values + .any((summary) => summary.strategyPublicId != activeStrategyId) || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId != activeStrategyId, + ); + final hasOtherStrategyAttention = + opQueueState.accountOutbox.strategies.values.any((summary) => + summary.strategyPublicId != activeStrategyId && + summary.needsAttention) || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId != activeStrategyId && job.isFailed, + ); + final hasActiveStrategyAttention = opQueueState.needsAttention || + saveState.cloudSyncError != null || + saveState.mediaSyncErrorCount > 0 || + mediaQueueState.jobs.any( + (job) => job.strategyPublicId == activeStrategyId && job.isFailed, + ); + final status = switch (ref.watch(cloudSyncStatusProvider)) { + CloudSyncStatus.synced => _SyncStatus.synced, + CloudSyncStatus.editing => _SyncStatus.editing, + CloudSyncStatus.syncing => _SyncStatus.syncing, + CloudSyncStatus.offline => _SyncStatus.offline, + CloudSyncStatus.attention => _SyncStatus.attention, + }; + + final syncTooltip = _tooltip(status, saveState.lastPersistedAt); + // The editor carries no status chips, so a viewer learns here why their + // edits do not stick. + final tooltip = ref.watch(lastKnownCloudRoleProvider) == 'viewer' + ? 'View only · $syncTooltip' + : syncTooltip; + final foreground = status == _SyncStatus.attention + ? Settings.tacticalVioletTheme.destructive + : null; + + return ShadPopover( + controller: _popoverController, + padding: const EdgeInsets.all(14), + anchor: const ShadAnchor( + offset: Offset(0, 8), + childAlignment: Alignment.topLeft, + overlayAlignment: Alignment.bottomLeft, + ), + popover: (context) => _SyncStatusPopover( + status: status, + upgradeRequired: upgradeRequired, + saveState: saveState, + rejectedCount: opQueueState.attentionByEntityKey.length, + lineupConflicts: ref.watch(lineupConflictsProvider), + hasOtherStrategyWork: hasOtherStrategyWork, + hasOtherStrategyAttention: hasOtherStrategyAttention, + hasActiveStrategyAttention: hasActiveStrategyAttention, + isResolving: _isResolving, + resolutionError: _resolutionError, + onRetry: _retry, + onUseCloudVersions: _useCloudVersions, + onKeepBoth: _keepBoth, + ), + child: EditorToolbarButton( + key: ValueKey('cloud-sync-button-${status.name}'), + style: widget.style, + tooltip: tooltip, + semanticsLabel: tooltip, + foregroundColor: foreground, + onPressed: () => _handlePressed(status), + icon: AnimatedSwitcher( + duration: _glyphSwitchDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: _glyph(status, foreground), + ), + ), + ); + } + + Widget _glyph(_SyncStatus status, Color? color) { + // Lucide's cloud sits low in its box and reads smaller than the upload + // and camera glyphs beside it, so it gets 2px more. + final size = widget.style.iconSize + 2; + switch (status) { + case _SyncStatus.synced: + return Icon( + LucideIcons.cloudCheck200, + key: const ValueKey('synced'), + size: size, + color: color, + ); + case _SyncStatus.editing: + return Icon( + LucideIcons.cloudUpload200, + key: const ValueKey('editing'), + size: size, + color: color, + ); + case _SyncStatus.syncing: + return SizedBox( + key: const ValueKey('syncing'), + width: size - 4, + height: size - 4, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + color ?? Settings.tacticalVioletTheme.mutedForeground, + ), + ), + ); + case _SyncStatus.offline: + return Icon( + LucideIcons.cloudOff200, + key: const ValueKey('offline'), + size: size, + color: color, + ); + case _SyncStatus.attention: + return Icon( + LucideIcons.cloudAlert200, + key: const ValueKey('attention'), + size: size, + color: color, + ); + } + } + + /// One line, what the glyph means and what a press will do. + static String _tooltip(_SyncStatus status, DateTime? lastSynced) { + switch (status) { + case _SyncStatus.synced: + return lastSynced == null + ? 'Synced' + : 'Synced at ${_SyncStatusPopover._formatTime(lastSynced)}'; + case _SyncStatus.editing: + return 'Edit not synced yet'; + case _SyncStatus.syncing: + return 'Syncing…'; + case _SyncStatus.offline: + return 'Offline, changes stay on this device'; + case _SyncStatus.attention: + return 'Sync needs attention'; + } + } +} + +class _SyncStatusPopover extends StatelessWidget { + const _SyncStatusPopover({ + required this.status, + required this.upgradeRequired, + required this.saveState, + required this.rejectedCount, + required this.lineupConflicts, + required this.hasOtherStrategyWork, + required this.hasOtherStrategyAttention, + required this.hasActiveStrategyAttention, + required this.isResolving, + required this.resolutionError, + required this.onRetry, + required this.onUseCloudVersions, + required this.onKeepBoth, + }); + + final _SyncStatus status; + + /// The server refuses this build. Nothing here can fix that, so the + /// popover says so and offers only the reload or update that can. + final bool upgradeRequired; + final StrategySaveState saveState; + final int rejectedCount; + + /// What each side did to the lineup groups in conflict, when the refused + /// work is all lineup groups on this page (see lineupConflictsProvider). + /// Then the popover lists the changes and offers Keep both. + final List? lineupConflicts; + final bool hasOtherStrategyWork; + final bool hasOtherStrategyAttention; + final bool hasActiveStrategyAttention; + final bool isResolving; + final String? resolutionError; + final Future Function() onRetry; + final Future Function() onUseCloudVersions; + final Future Function() onKeepBoth; + + bool get hasRejectedWork => rejectedCount > 0; + + /// The lineup changes to list, when there is no more specific reason to + /// show instead (a deleted page, an oversized change). + List? get _shownLineupConflicts { + final error = saveState.cloudSyncError; + if (error != null && isSpecificAttentionReason(error)) return null; + return lineupConflicts; + } + + @override + Widget build(BuildContext context) { + if (upgradeRequired) { + return ClientUpgradeNotice( + buttonSize: ShadButtonSize.sm, + builder: (context, message, action) => _layout( + context, + explanation: message, + actions: action == null + ? null + : Align(alignment: Alignment.centerRight, child: action), + ), + ); + } + return _layout( + context, + explanation: _explanation, + actions: status == _SyncStatus.attention && + (!hasOtherStrategyAttention || hasActiveStrategyAttention) + ? Wrap( + alignment: WrapAlignment.end, + spacing: 8, + runSpacing: 8, + children: [ + if (hasRejectedWork) + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onUseCloudVersions, + child: const Text('Use cloud'), + ), + if (_shownLineupConflicts != null) + ShadButton.secondary( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onKeepBoth, + child: const Text('Keep both'), + ), + ShadButton( + size: ShadButtonSize.sm, + expands: false, + onPressed: isResolving ? null : onRetry, + child: Text( + hasRejectedWork ? 'Keep mine' : 'Retry sync', + ), + ), + ], + ) + : null, + ); + } + + Widget _layout( + BuildContext context, { + required String explanation, + required Widget? actions, + }) { + final theme = ShadTheme.of(context); + final lastSynced = saveState.lastPersistedAt; + + // Text alignment is inherited from the editor, which centers, so pin it + // here; the column's own alignment does not reach inside the Texts. + return DefaultTextStyle.merge( + textAlign: TextAlign.start, + child: SizedBox( + width: 260, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _title, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.foreground, + ), + ), + const SizedBox(height: 6), + Text( + explanation, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + height: 1.35, + ), + ), + if (_shownLineupConflicts case final conflicts?) + _LineupConflictList(conflicts: conflicts), + if (resolutionError != null) ...[ + const SizedBox(height: 8), + Text( + resolutionError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + height: 1.35, + ), + ), + ], + if (lastSynced != null) ...[ + const SizedBox(height: 8), + Text( + 'Last synced at ${_formatTime(lastSynced)}', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 11, + ), + ), + ], + if (actions != null) ...[ + const SizedBox(height: 12), + actions, + ], + ], + ), + ), + ); + } + + String get _title { + switch (status) { + case _SyncStatus.synced: + return 'All changes synced'; + case _SyncStatus.editing: + return 'Edit not synced yet'; + case _SyncStatus.syncing: + return 'Syncing changes'; + case _SyncStatus.offline: + return 'Working offline'; + case _SyncStatus.attention: + return 'Sync needs attention'; + } + } + + String get _explanation { + switch (status) { + case _SyncStatus.synced: + return 'Your strategy is safely stored in the cloud.'; + case _SyncStatus.editing: + return 'Finish editing or switch pages to send this change to the ' + 'cloud.'; + case _SyncStatus.syncing: + return hasOtherStrategyWork + ? 'Saved changes from your cloud library are being sent in the ' + 'background.' + : 'Your edits are being sent to the cloud. You can keep ' + 'working — this happens in the background.'; + case _SyncStatus.offline: + return 'Changes are kept on this device and will sync automatically ' + 'when your connection returns.'; + case _SyncStatus.attention: + const otherStrategyExplanation = + 'Saved work in another strategy also needs attention. Open it ' + 'from the Cloud library to review the reason.'; + if (!hasOtherStrategyAttention) return _attentionExplanation; + if (hasActiveStrategyAttention) { + return '$_attentionExplanation $otherStrategyExplanation'; + } + return otherStrategyExplanation.replaceFirst(' also', ''); + } + } + + String get _attentionExplanation { + final mediaErrors = saveState.mediaSyncErrorCount; + final parts = []; + final error = saveState.cloudSyncError; + final hasSpecificReason = error != null && isSpecificAttentionReason(error); + if (_shownLineupConflicts != null) { + parts.add( + 'A teammate changed these lineups while you were working on them. ' + 'Your version remains saved on this device.', + ); + parts.add( + 'Keep mine replaces their changes, Use cloud replaces yours, and ' + 'Keep both adds yours beside theirs as a copy.', + ); + } else if (hasRejectedWork && !hasSpecificReason) { + parts.add( + 'Another edit reached the cloud first. Your version remains saved ' + 'on this device.', + ); + parts.add( + rejectedCount == 1 + ? 'Choose which version to keep for this conflicting change.' + : 'Your choice applies to all $rejectedCount conflicting changes.', + ); + } + final retryUnavailable = + error?.toLowerCase().contains('cannot be retried automatically') ?? + false; + if (error != null && + (!hasRejectedWork || retryUnavailable || hasSpecificReason)) { + parts.add(friendlyCloudSyncError(error)); + } + if (error?.contains(otherWorkNeedsAttentionNote) ?? false) { + parts.add( + 'Other changes here were not saved either. They remain on this ' + 'device.', + ); + } + if (hasRejectedWork && hasSpecificReason && rejectedCount > 1) { + parts.add( + 'Your choice applies to all $rejectedCount changes that need ' + 'attention.', + ); + } + if (mediaErrors > 0) { + parts.add( + mediaErrors == 1 + ? 'One image failed to upload.' + : '$mediaErrors images failed to upload.', + ); + } + if (parts.isEmpty) { + parts.add("Some changes haven't reached the cloud yet."); + } + if (!hasRejectedWork) parts.add('Retry to send them now.'); + return parts.join(' '); + } + + static String _formatTime(DateTime time) { + final hour = time.hour.toString().padLeft(2, '0'); + final minute = time.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; + } +} + +/// The lineups each side changed in the conflicting lineup groups: what +/// Keep mine would replace (the cloud's changes) and what Use cloud would +/// (the user's), so neither choice drops work the user cannot see. +class _LineupConflictList extends StatelessWidget { + const _LineupConflictList({required this.conflicts}); + + final List conflicts; + + /// More lines than this read as a wall; the rest are counted instead. + static const _maxLines = 4; + + @override + Widget build(BuildContext context) { + final yours = [for (final conflict in conflicts) ...conflict.yours]; + // Without the version both sides started from, only the difference + // between them can be shown, not who made it. + if (conflicts.any((conflict) => conflict.cloud == null)) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + _section(context, 'How your version differs from the cloud', yours), + ], + ); + } + final cloud = [for (final conflict in conflicts) ...?conflict.cloud]; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + _section(context, 'Your changes', yours), + const SizedBox(height: 8), + _section(context, 'Cloud changes', cloud), + ], + ); + } + + Widget _section( + BuildContext context, + String title, + List changes, + ) { + final theme = ShadTheme.of(context); + final lineStyle = theme.textTheme.small.copyWith( + color: theme.colorScheme.foreground, + fontSize: 12, + height: 1.35, + ); + final mutedStyle = lineStyle.copyWith( + color: theme.colorScheme.mutedForeground, + ); + final shown = changes.take(_maxLines).toList(); + final hidden = changes.length - shown.length; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + if (changes.isEmpty) Text('No lineup changes', style: mutedStyle), + for (final change in shown) + Text.rich( + TextSpan( + children: [ + TextSpan(text: change.label), + TextSpan(text: ' · ${change.description}', style: mutedStyle), + ], + ), + style: lineStyle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (hidden > 0) + Text( + hidden == 1 ? 'and 1 more lineup' : 'and $hidden more lineups', + style: mutedStyle, + ), + ], + ); + } +} diff --git a/lib/widgets/custom_text_field.dart b/lib/widgets/custom_text_field.dart index 85684865..b15da7f6 100644 --- a/lib/widgets/custom_text_field.dart +++ b/lib/widgets/custom_text_field.dart @@ -1,41 +1,177 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -class CustomTextField extends ConsumerWidget { +class CustomTextField extends StatefulWidget { const CustomTextField({ super.key, this.controller, + this.focusNode, this.hintText, this.textAlign, this.minLines, this.maxLines, this.onSubmitted, + this.keyboardType, + this.autofillHints, + this.obscureText = false, + this.textInputAction, + this.hasError = false, this.autofocus = false, - // required this.onEnterPressed, }); final TextEditingController? controller; + final FocusNode? focusNode; final String? hintText; final TextAlign? textAlign; final int? minLines; final int? maxLines; final Function(String)? onSubmitted; + final TextInputType? keyboardType; + final Iterable? autofillHints; + final bool obscureText; + final TextInputAction? textInputAction; + + /// Draws a destructive border when true (e.g. failed validation). + final bool hasError; final bool autofocus; - // final Function(EnterTextIntent intent) onEnterPressed; - - @override - Widget build(BuildContext context, WidgetRef ref) { - return TextEditingShortcutScope( - child: ShadInput( - controller: controller, - autofocus: autofocus, - textAlign: textAlign ?? TextAlign.start, - minLines: minLines, - maxLines: maxLines ?? 1, - placeholder: hintText != null ? Text(hintText!) : null, - onSubmitted: onSubmitted, + + @override + State createState() => _CustomTextFieldState(); +} + +class _CustomTextFieldState extends State { + late final TextEditingController _fallbackController; + late final FocusNode _fallbackFocusNode; + + TextEditingController get _controller => + widget.controller ?? _fallbackController; + FocusNode get _focusNode => widget.focusNode ?? _fallbackFocusNode; + + @override + void initState() { + super.initState(); + _fallbackController = TextEditingController(); + _fallbackFocusNode = FocusNode(); + } + + @override + void dispose() { + _fallbackController.dispose(); + _fallbackFocusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _EditableTextFieldSemantics( + label: widget.hintText, + controller: _controller, + focusNode: _focusNode, + obscureText: widget.obscureText, + child: TextEditingShortcutScope( + child: ShadInput( + decoration: widget.hasError + ? ShadDecoration( + border: ShadBorder.all( + color: ShadTheme.of(context).colorScheme.destructive, + ), + ) + : null, + controller: _controller, + focusNode: _focusNode, + autofocus: widget.autofocus, + textAlign: widget.textAlign ?? TextAlign.start, + minLines: widget.minLines, + maxLines: widget.maxLines ?? 1, + keyboardType: widget.keyboardType, + autofillHints: widget.autofillHints, + obscureText: widget.obscureText, + textInputAction: widget.textInputAction, + placeholder: widget.hintText != null ? Text(widget.hintText!) : null, + onSubmitted: widget.onSubmitted, + ), ), ); } } + +class _EditableTextFieldSemantics extends StatefulWidget { + const _EditableTextFieldSemantics({ + required this.label, + required this.controller, + required this.focusNode, + required this.obscureText, + required this.child, + }); + + final String? label; + final TextEditingController controller; + final FocusNode focusNode; + final bool obscureText; + final Widget child; + + @override + State<_EditableTextFieldSemantics> createState() => + _EditableTextFieldSemanticsState(); +} + +class _EditableTextFieldSemanticsState + extends State<_EditableTextFieldSemantics> { + @override + void initState() { + super.initState(); + widget.controller.addListener(_refresh); + widget.focusNode.addListener(_refresh); + } + + @override + void didUpdateWidget(covariant _EditableTextFieldSemantics oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_refresh); + widget.controller.addListener(_refresh); + } + if (!identical(oldWidget.focusNode, widget.focusNode)) { + oldWidget.focusNode.removeListener(_refresh); + widget.focusNode.addListener(_refresh); + } + } + + @override + void dispose() { + widget.controller.removeListener(_refresh); + widget.focusNode.removeListener(_refresh); + super.dispose(); + } + + void _refresh() { + if (mounted) setState(() {}); + } + + void _setText(String value) { + widget.controller.value = TextEditingValue( + text: value, + selection: TextSelection.collapsed(offset: value.length), + ); + widget.focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final text = widget.controller.text; + return Semantics( + label: widget.label, + value: widget.obscureText ? '•' * text.length : text, + textField: true, + enabled: true, + obscured: widget.obscureText, + focusable: true, + focused: widget.focusNode.hasFocus, + onTap: widget.focusNode.requestFocus, + onSetText: _setText, + onDidGainAccessibilityFocus: widget.focusNode.requestFocus, + excludeSemantics: true, + child: widget.child, + ); + } +} diff --git a/lib/widgets/demo_dialog.dart b/lib/widgets/demo_dialog.dart deleted file mode 100644 index caf87a48..00000000 --- a/lib/widgets/demo_dialog.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; -import 'package:url_launcher/url_launcher.dart' show launchUrl; - -class DemoDialog extends ConsumerWidget { - const DemoDialog({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return ShadDialog.alert( - title: const Text('Demo Version'), - description: const SizedBox( - width: 400, - child: Text( - 'You are running the web version of this application, which has limited functionality. ' - 'For the best experience, please install the Windows version from the Microsoft Store — it is free.', - ), - ), - actions: [ - ShadButton.secondary( - leading: const Icon( - LucideIcons.x, - ), - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Close'), - ), - ShadButton( - leading: const Icon(LucideIcons.download), - onPressed: () async { - await launchUrl(Settings.windowsStoreLink); - }, - child: const Text('Download'), - ) - ], - ); - } -} diff --git a/lib/widgets/demo_tag.dart b/lib/widgets/demo_tag.dart deleted file mode 100644 index 3100be63..00000000 --- a/lib/widgets/demo_tag.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:icarus/widgets/demo_dialog.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class DemoTag extends ConsumerWidget { - const DemoTag({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - const accent = Colors.red; - - return InkWell( - mouseCursor: SystemMouseCursors.click, - onTap: () async { - await showShadDialog( - context: context, - builder: (context) { - return const DemoDialog(); - }, - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: accent.withAlpha(31), // 0.12 - border: Border.all(color: accent, width: 1.5), - borderRadius: BorderRadius.circular(10), - boxShadow: [ - BoxShadow( - color: accent.withAlpha(64), // 0.25 - blurRadius: 8, - spreadRadius: 1, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.flaskConical, - size: 14, - color: Colors.white.withAlpha(230), // 0.9 - ), - const SizedBox(width: 6), - Text( - 'DEMO', - style: theme.textTheme.labelMedium?.copyWith( - color: Colors.white, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ) ?? - const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/dialogs/auth/auth_dialog.dart b/lib/widgets/dialogs/auth/auth_dialog.dart new file mode 100644 index 00000000..bfa0c57a --- /dev/null +++ b/lib/widgets/dialogs/auth/auth_dialog.dart @@ -0,0 +1,382 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/widgets/custom_text_field.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +enum AuthDialogMode { signIn, signUp } + +enum _AuthField { email, password, confirm } + +const _bannerDuration = Duration(milliseconds: 180); + +class AuthDialog extends ConsumerStatefulWidget { + const AuthDialog({ + super.key, + this.initialMode = AuthDialogMode.signIn, + }); + + final AuthDialogMode initialMode; + + @override + ConsumerState createState() => _AuthDialogState(); +} + +class _AuthDialogState extends ConsumerState { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmController = TextEditingController(); + final FocusNode _emailFocusNode = FocusNode(); + final FocusNode _passwordFocusNode = FocusNode(); + final FocusNode _confirmFocusNode = FocusNode(); + bool _submitting = false; + bool _isSignUp = false; + bool _waitingForDiscord = false; + String? _message; + bool _messageIsInfo = false; + _AuthField? _errorField; + + @override + void initState() { + super.initState(); + _isSignUp = widget.initialMode == AuthDialogMode.signUp; + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); + _emailFocusNode.dispose(); + _passwordFocusNode.dispose(); + _confirmFocusNode.dispose(); + super.dispose(); + } + + void _setValidationError(String message, _AuthField field) { + setState(() { + _message = message; + _messageIsInfo = false; + _errorField = field; + }); + } + + Future _submit() async { + final email = _emailController.text.trim(); + final password = _passwordController.text; + final confirm = _confirmController.text; + + if (email.isEmpty || !email.contains('@')) { + _setValidationError('Enter a valid email address.', _AuthField.email); + return; + } + + if (password.length < 6) { + _setValidationError( + 'Password must be at least 6 characters.', + _AuthField.password, + ); + return; + } + + if (_isSignUp && password != confirm) { + _setValidationError('Passwords do not match.', _AuthField.confirm); + return; + } + + setState(() { + _submitting = true; + _message = null; + _messageIsInfo = false; + _errorField = null; + }); + + final notifier = ref.read(authProvider.notifier); + final error = _isSignUp + ? await notifier.signUpWithEmailPassword( + email: email, password: password) + : await notifier.signInWithEmailPassword( + email: email, password: password); + + if (!mounted) return; + + setState(() { + _submitting = false; + _message = error; + // The "account created" outcome comes back through the error channel + // but is good news — present it as info, not failure. + _messageIsInfo = error != null && error.startsWith('Account created'); + }); + + if (error == null) { + Navigator.of(context).pop(true); + } + } + + void _startDiscordSignIn() { + setState(() { + _waitingForDiscord = true; + _message = null; + _messageIsInfo = false; + _errorField = null; + }); + unawaited(ref.read(authProvider.notifier).signInWithDiscord()); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final busy = _submitting || authState.isLoading; + + // The Discord flow completes via a browser round-trip and deep link: + // close the dialog once the session actually lands, and drop the pending + // state if the provider reports a failure instead. + ref.listen(authProvider, (previous, next) { + if (!_waitingForDiscord) { + return; + } + if (next.isAuthenticated && previous?.isAuthenticated != true) { + Navigator.of(context).pop(true); + return; + } + if (next.errorMessage != null && + next.errorMessage != previous?.errorMessage) { + setState(() { + _waitingForDiscord = false; + _message = next.errorMessage; + _messageIsInfo = false; + }); + } + }); + + return ShadDialog( + title: Text(_isSignUp ? 'Create account' : 'Sign in'), + description: Text( + _isSignUp + ? 'Use email and password to create an account.' + : 'Sign in with email and password.', + ), + child: SizedBox( + width: 420, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomTextField( + key: const ValueKey('auth-email-field'), + controller: _emailController, + focusNode: _emailFocusNode, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + hintText: 'Email', + textInputAction: TextInputAction.next, + hasError: _errorField == _AuthField.email, + ), + const SizedBox(height: 10), + CustomTextField( + key: const ValueKey('auth-password-field'), + controller: _passwordController, + focusNode: _passwordFocusNode, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Password', + textInputAction: + _isSignUp ? TextInputAction.next : TextInputAction.done, + onSubmitted: _isSignUp ? null : (_) => _submit(), + hasError: _errorField == _AuthField.password, + ), + if (_isSignUp) ...[ + const SizedBox(height: 10), + CustomTextField( + key: const ValueKey('auth-confirm-password-field'), + controller: _confirmController, + focusNode: _confirmFocusNode, + obscureText: true, + autofillHints: const [AutofillHints.password], + hintText: 'Confirm password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + hasError: _errorField == _AuthField.confirm, + ), + ], + const SizedBox(height: 12), + AnimatedSize( + duration: _bannerDuration, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _message != null + ? Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _AuthMessageBanner( + message: _message!, + isInfo: _messageIsInfo, + ), + ) + : const SizedBox(width: double.infinity), + ), + AnimatedSize( + duration: _bannerDuration, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _waitingForDiscord + ? Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _DiscordPendingBanner( + onCancel: () { + setState(() => _waitingForDiscord = false); + }, + ), + ) + : const SizedBox(width: double.infinity), + ), + Row( + children: [ + Expanded( + child: ShadButton.secondary( + key: const ValueKey('auth-mode-switch'), + onPressed: busy + ? null + : () { + setState(() { + _isSignUp = !_isSignUp; + _message = null; + _messageIsInfo = false; + _errorField = null; + }); + }, + child: Text( + _isSignUp ? 'Sign in instead' : 'Create an account', + ), + ), + ), + ], + ), + const SizedBox(height: 10), + ShadButton.secondary( + key: const ValueKey('auth-discord-button'), + onPressed: + busy || _waitingForDiscord ? null : _startDiscordSignIn, + child: const Text('Continue with Discord'), + ), + const SizedBox(height: 10), + ShadButton( + key: const ValueKey('auth-submit-button'), + onPressed: busy ? null : _submit, + leading: busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : null, + child: Text( + busy + ? (_isSignUp ? 'Creating…' : 'Signing in…') + : (_isSignUp ? 'Create account' : 'Sign in'), + ), + ), + ], + ), + ), + ); + } +} + +class _AuthMessageBanner extends StatelessWidget { + const _AuthMessageBanner({ + required this.message, + required this.isInfo, + }); + + final String message; + final bool isInfo; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final color = + isInfo ? theme.colorScheme.primary : theme.colorScheme.destructive; + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isInfo ? LucideIcons.mailCheck : LucideIcons.circleAlert, + size: 15, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: theme.textTheme.small.copyWith( + color: color, + fontSize: 12, + height: 1.35, + ), + ), + ), + ], + ), + ); + } +} + +class _DiscordPendingBanner extends StatelessWidget { + const _DiscordPendingBanner({required this.onCancel}); + + final VoidCallback onCancel; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.mutedForeground, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Finish signing in with Discord in your browser — this dialog ' + 'will close automatically.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + height: 1.35, + ), + ), + ), + const SizedBox(width: 8), + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onCancel, + child: const Text('Cancel'), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dialogs/confirm_alert_dialog.dart b/lib/widgets/dialogs/confirm_alert_dialog.dart index 20326449..8fd2023d 100644 --- a/lib/widgets/dialogs/confirm_alert_dialog.dart +++ b/lib/widgets/dialogs/confirm_alert_dialog.dart @@ -10,42 +10,64 @@ class ConfirmAlertDialog extends ConsumerWidget { this.confirmText = "Confirm", this.cancelText = "Cancel", this.isDestructive = false, + this.body, }); final String title; final String content; + + /// Shown instead of [content] when the message needs styling. + final Widget? body; final String confirmText; final String cancelText; final bool isDestructive; // For dangerous actions like delete @override Widget build(BuildContext context, WidgetRef ref) { + void cancel() => Navigator.of(context).pop(false); + void confirm() => Navigator.of(context).pop(true); + return ShadDialog.alert( title: Text(title), description: Padding( padding: const EdgeInsets.all(8.0), - child: Text(content), + child: body ?? Text(content), ), actions: [ - ShadButton.secondary( - child: Text(cancelText), - onPressed: () { - Navigator.of(context).pop(false); - }, + Semantics( + key: const ValueKey('confirm-alert-cancel'), + label: cancelText, + button: true, + onTap: cancel, + excludeSemantics: true, + child: ShadButton.secondary( + onPressed: cancel, + child: Text(cancelText), + ), ), if (isDestructive) - ShadButton.destructive( - child: Text(confirmText), - onPressed: () { - Navigator.of(context).pop(true); - }, + Semantics( + key: const ValueKey('confirm-alert-confirm'), + label: confirmText, + button: true, + onTap: confirm, + excludeSemantics: true, + child: ShadButton.destructive( + onPressed: confirm, + child: Text(confirmText), + ), ) else - ShadButton( - child: Text(confirmText), - onPressed: () { - Navigator.of(context).pop(true); - }, + Semantics( + key: const ValueKey('confirm-alert-confirm'), + label: confirmText, + button: true, + onTap: confirm, + excludeSemantics: true, + child: ShadButton( + onPressed: confirm, + child: Text(confirmText), + ), ), ], ); @@ -59,6 +81,7 @@ class ConfirmAlertDialog extends ConsumerWidget { String confirmText = "Confirm", String cancelText = "Cancel", bool isDestructive = false, + Widget? body, }) async { final result = await showShadDialog( context: context, @@ -68,6 +91,7 @@ class ConfirmAlertDialog extends ConsumerWidget { confirmText: confirmText, cancelText: cancelText, isDestructive: isDestructive, + body: body, ), ); diff --git a/lib/widgets/dialogs/create_lineup_dialog.dart b/lib/widgets/dialogs/create_lineup_dialog.dart index 7d529cb3..f8faaaa8 100644 --- a/lib/widgets/dialogs/create_lineup_dialog.dart +++ b/lib/widgets/dialogs/create_lineup_dialog.dart @@ -1,16 +1,22 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/services/clipboard_service.dart'; import 'package:icarus/services/analytics_service.dart'; import 'package:icarus/widgets/dialogs/strategy/line_up_media_page.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:path/path.dart' as path; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; @@ -26,11 +32,143 @@ class CreateLineupDialog extends ConsumerStatefulWidget { ConsumerState createState() => _CreateLineupDialogState(); } +/// Images picked in one lineup dialog that no lineup references yet. Their +/// bytes are kept as soon as they are picked (on web, for upload); they are +/// let go when removed, or when the dialog closes without saving. +class LineupImageDrafts { + LineupImageDrafts(this._images, {required this.strategyId}); + + final PlacedImageProvider _images; + final String? strategyId; + final Set _ids = {}; + bool _closed = false; + + /// Keeps [bytes] as a new draft. Returns null, keeping nothing, when the + /// dialog closed while they were being kept. Throws + /// [MediaTooLargeException], keeping nothing, when the image can never + /// upload. + Future add(Uint8List bytes, String fileExtension) async { + final id = const Uuid().v4(); + await _images.saveSecureImage( + bytes, + id, + fileExtension, + strategyId: strategyId, + ); + if (_closed) { + await _discard(id); + return null; + } + _ids.add(id); + return SimpleImageData(id: id, fileExtension: fileExtension); + } + + /// Lets go of [imageId] if it is a draft; saved images are left alone. + Future remove(String imageId) async { + if (_ids.remove(imageId)) await _discard(imageId); + } + + /// Hands every draft to the upload queue before their jobs are written, so + /// nothing can let their bytes go meanwhile. Pass the result to [takeBack] + /// if queuing fails. + Set handOver() { + final ids = {..._ids}; + _ids.clear(); + return ids; + } + + /// Queuing [ids] failed: they are drafts again. + Future takeBack(Set ids) async { + if (!_closed) { + _ids.addAll(ids); + return; + } + for (final id in ids) { + await _discard(id); + } + } + + /// The dialog closed. Drafts it still owns go, and so does any pick that + /// finishes after this. + Future dismissed() async { + _closed = true; + final ids = _ids.toList(); + _ids.clear(); + for (final id in ids) { + await _discard(id); + } + } + + Future _discard(String id) => + _images.discardDraftImage(imageId: id, strategyId: strategyId); +} + class _CreateLineupDialogState extends ConsumerState { final TextEditingController _nameController = TextEditingController(); final TextEditingController _youtubeLinkController = TextEditingController(); final TextEditingController _notesController = TextEditingController(); final List _imagePaths = []; + final Set _initialImageIds = {}; + late final LineupImageDrafts _drafts = LineupImageDrafts( + ref.read(placedImageProvider.notifier), + strategyId: ref.read(strategyProvider).strategyId, + ); + + // While Save queues the images, the dialog cannot be closed. + bool _saving = false; + + Future _addDraftImage(Uint8List bytes, String fileExtension) async { + final SimpleImageData? image; + try { + image = await _drafts.add(bytes, fileExtension); + } on MediaTooLargeException catch (error) { + Settings.showToast( + message: error.userMessage, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + if (image == null) return; + if (!mounted) { + await _drafts.remove(image.id); + return; + } + setState(() => _imagePaths.add(image!)); + } + + Future _enqueueLineupMediaJobs({ + required List images, + }) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + strategyState.strategyId == null) { + return; + } + + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: strategyState.strategyId!, + images: images, + ); + } + + Future _commitLineupMediaJobs({ + required List images, + }) async { + final strategyState = ref.read(strategyProvider); + if (strategyState.source != StrategySource.cloud || + strategyState.strategyId == null || + images.isEmpty) { + return; + } + await ref + .read(cloudMediaUploadQueueProvider.notifier) + .commitStagedMediaReferences( + strategyPublicId: strategyState.strategyId!, + assetPublicIds: images.map((image) => image.id), + ); + } bool get _isEditing => widget.linkId != null; @@ -44,12 +182,37 @@ class _CreateLineupDialogState extends ConsumerState { _youtubeLinkController.text = link.youtubeLink; _notesController.text = link.notes; _imagePaths.addAll(link.images); + _initialImageIds.addAll(link.images.map((image) => image.id)); } + // Editing a lineup counts as editing its group (see + // myLineupEditingProvider); providers change after this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref + .read(openLineUpItemsProvider.notifier) + .open(_openToken, {widget.linkId!}); + }); } } + /// Names this dialog in openLineUpItemsProvider. + final Object _openToken = Object(); + ProviderContainer? _container; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _container ??= ProviderScope.containerOf(context, listen: false); + } + @override void dispose() { + final container = _container; + if (container != null && _isEditing) { + WidgetsBinding.instance.addPostFrameCallback((_) { + container.read(openLineUpItemsProvider.notifier).close(_openToken); + }); + } _nameController.dispose(); _youtubeLinkController.dispose(); _notesController.dispose(); @@ -59,24 +222,43 @@ class _CreateLineupDialogState extends ConsumerState { Future _save() async { final notifier = ref.read(lineUpProvider.notifier); final name = _nameController.text.trim(); + final existing = _isEditing ? notifier.linkById(widget.linkId!) : null; + if (_isEditing && existing == null) return; + if (!_isEditing && + !(ref.read(lineUpProvider).placement?.isComplete ?? false)) { + return; + } - if (_isEditing) { - final existing = notifier.linkById(widget.linkId!); - if (existing != null) { - ref.read(actionProvider.notifier).performTransaction( - groups: const [ActionGroup.lineUp], - mutation: () { - notifier.updateLink( - existing.copyWith( - name: name, - youtubeLink: _youtubeLinkController.text, - notes: _notesController.text, - images: _imagePaths, - ), - ); - }, - ); - } + // Cloud images are queued before the lineup references them, so a + // lineup never points at an image the server will not receive. + final imagesNeedingUpload = _imagePaths + .where((image) => !_initialImageIds.contains(image.id)) + .toList(growable: false); + // The queue owns these images from here, before their jobs are written. + setState(() => _saving = true); + final handedOver = _drafts.handOver(); + try { + await _enqueueLineupMediaJobs(images: imagesNeedingUpload); + } catch (_) { + await _drafts.takeBack(handedOver); + if (mounted) setState(() => _saving = false); + Settings.showToast( + message: 'Could not queue these images for cloud sync. ' + 'They remain on this device. Try again before closing.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + + if (existing != null) { + notifier.updateLink( + existing.copyWith( + name: name, + youtubeLink: _youtubeLinkController.text, + notes: _notesController.text, + images: _imagePaths, + ), + ); } else { final link = notifier.commitPlacement( name: name, @@ -102,6 +284,16 @@ class _CreateLineupDialogState extends ConsumerState { ); } + try { + await _commitLineupMediaJobs(images: imagesNeedingUpload); + } catch (_) { + Settings.showToast( + message: 'The lineup is kept in the editor, but its cloud work is ' + 'still pending. Use Save before leaving.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); @@ -113,8 +305,12 @@ class _CreateLineupDialogState extends ConsumerState { @override Widget build(BuildContext context) { return PopScope( + // Barrier taps and Escape wait for Save to finish queuing. + canPop: !_saving, onPopInvokedWithResult: (didPop, result) { if (didPop) { + // Images handed to the queue by Save are not drafts any more. + unawaited(_drafts.dismissed()); ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); @@ -122,9 +318,14 @@ class _CreateLineupDialogState extends ConsumerState { }, child: ShadDialog( title: Text(_isEditing ? "Edit Lineup" : "Create Lineup"), + description: + _isEditing ? LineUpEditorsNotice(itemId: widget.linkId!) : null, + // The close button pops directly, past PopScope, so it steps aside + // while Save is queuing. + closeIcon: _saving ? const SizedBox.shrink() : null, actions: [ ShadButton( - onPressed: _save, + onPressed: _saving ? null : _save, child: const Text("Done"), ), ], @@ -141,24 +342,16 @@ class _CreateLineupDialogState extends ConsumerState { allowMultiple: false, type: FileType.custom, allowedExtensions: ["png", "jpg", "gif", "webp", "bmp"], + // The browser hands over bytes, never a path. + withData: true, ); - if (result == null) return; - final imageFile = result.files.first.xFile; - final fileExtension = path.extension(imageFile.path); - final imageBytes = await imageFile.readAsBytes(); - final id = const Uuid().v4(); - - final imageData = - SimpleImageData(id: id, fileExtension: fileExtension); - - await ref - .read(placedImageProvider.notifier) - .saveSecureImage(imageBytes, id, fileExtension); - - setState(() { - _imagePaths.add(imageData); - }); + if (result == null || result.files.isEmpty) return; + final picked = result.files.first; + final fileExtension = path.extension(picked.name); + final imageBytes = + picked.bytes ?? await picked.xFile.readAsBytes(); + await _addDraftImage(imageBytes, fileExtension); }, onPasteImage: () async { final (bytes, _) = @@ -182,22 +375,12 @@ class _CreateLineupDialogState extends ConsumerState { return; } - final id = const Uuid().v4(); - final imageData = - SimpleImageData(id: id, fileExtension: fileExtension); - - await ref - .read(placedImageProvider.notifier) - .saveSecureImage(bytes, id, fileExtension); - - setState(() { - _imagePaths.add(imageData); - }); + await _addDraftImage(bytes, fileExtension); }, onRemoveImage: (index) { - setState(() { - _imagePaths.removeAt(index); - }); + final removed = _imagePaths[index]; + setState(() => _imagePaths.removeAt(index)); + unawaited(_drafts.remove(removed.id)); }, ), ), diff --git a/lib/widgets/dialogs/delete_folder_alert_dialog.dart b/lib/widgets/dialogs/delete_folder_alert_dialog.dart new file mode 100644 index 00000000..81f8ff66 --- /dev/null +++ b/lib/widgets/dialogs/delete_folder_alert_dialog.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class DeleteFolderAlertDialog extends ConsumerStatefulWidget { + const DeleteFolderAlertDialog({ + super.key, + required this.folder, + required this.workspace, + }); + + final Folder folder; + final LibraryWorkspace workspace; + + @override + ConsumerState createState() => + _DeleteFolderAlertDialogState(); +} + +class _DeleteFolderAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + CloudLibraryActionResult? result; + try { + result = await ref.read(folderProvider.notifier).deleteFolder( + widget.folder.id, + workspace: widget.workspace, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to delete a library folder.', + source: 'folder_dialog:delete', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't delete this folder. Try again."; + }); + } + } finally { + if (mounted) setState(() => _isDeleting = false); + } + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + return; + } + if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't delete this folder. Try again."); + } + } + + @override + Widget build(BuildContext context) { + return ShadDialog.alert( + title: Text("Delete '${widget.folder.name}'?"), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'This also removes every strategy and subfolder inside it.', + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-folder-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + ], + ], + ), + actions: [ + ShadButton.secondary( + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton.destructive( + key: const ValueKey('delete-folder-confirm'), + onPressed: _isDeleting ? null : _delete, + child: Text(_isDeleting ? 'Deleting...' : 'Delete'), + ), + ], + ); + } +} diff --git a/lib/widgets/dialogs/delete_page_dialog.dart b/lib/widgets/dialogs/delete_page_dialog.dart new file mode 100644 index 00000000..d912be38 --- /dev/null +++ b/lib/widgets/dialogs/delete_page_dialog.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; + +/// Asks before the page [pageId] is deleted. If teammates are on it right +/// now, as far as presence knows, it says who first: the page goes from +/// under them. Presence is best-effort, so this only warns; confirming +/// always deletes. A cloud strategy's page can be restored from Recently +/// deleted for a while, and the dialog says so; a local one is gone for +/// good. Returns whether the user confirmed. +Future confirmDeletePage( + BuildContext context, + WidgetRef ref, { + required String pageId, + required String pageName, +}) { + final peers = ref.read(strategyPresenceProvider).peopleOn(pageId).toList(); + final afterwards = ref.read(strategyProvider).source == StrategySource.cloud + ? "You can restore it from Recently deleted for " + "$pageTrashRetentionDays days." + : "This action cannot be undone."; + final warning = " on this page right now. Delete it anyway? $afterwards"; + return ConfirmAlertDialog.show( + context: context, + title: "Delete '$pageName'?", + content: peers.isEmpty + ? "Are you sure you want to delete this page? $afterwards" + : "${_peopleHere([for (final peer in peers) peer.name])}$warning", + // Each name in bold, in the colour their cursor has on the map. + body: peers.isEmpty + ? null + : Text.rich(TextSpan(children: [ + ..._peopleHereSpans(peers), + TextSpan(text: warning), + ])), + confirmText: peers.isEmpty ? "Delete" : "Delete anyway", + cancelText: "Cancel", + isDestructive: true, + ); +} + +TextSpan _name(PresencePeer peer) => TextSpan( + text: peer.name, + style: TextStyle( + fontWeight: FontWeight.bold, + color: presenceColorFor(peer.uid), + ), + ); + +/// [_peopleHere] with each named teammate styled by [_name]. +List _peopleHereSpans(List peers) => switch (peers) { + [final only] => [_name(only), const TextSpan(text: ' is')], + [final first, final second] => [ + _name(first), + const TextSpan(text: ' and '), + _name(second), + const TextSpan(text: ' are'), + ], + [final first, final second, ...final rest] => [ + _name(first), + const TextSpan(text: ', '), + _name(second), + TextSpan( + text: ' and ${rest.length} ' + '${rest.length == 1 ? 'other' : 'others'} are', + ), + ], + [] => const [], + }; + +/// "Alex is", "Alex and Sam are", "Alex, Sam and 2 others are". +String _peopleHere(List names) => switch (names) { + [final only] => '$only is', + [final first, final second] => '$first and $second are', + [final first, final second, ...final rest] => + '$first, $second and ${rest.length} ' + '${rest.length == 1 ? 'other' : 'others'} are', + [] => '', + }; diff --git a/lib/widgets/dialogs/deleted_page_dialog.dart b/lib/widgets/dialogs/deleted_page_dialog.dart new file mode 100644 index 00000000..1cca5b0f --- /dev/null +++ b/lib/widgets/dialogs/deleted_page_dialog.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Tells the user that the page on screen was deleted (by a teammate, +/// almost always) while it held their unsaved work, and lets them restore +/// the page, which saves that work, or discard the work. The canvas stays on the page until they +/// choose, so the dialog cannot be dismissed any other way. +class DeletedPageDialog extends ConsumerStatefulWidget { + const DeletedPageDialog({super.key, required this.page}); + + final DeletedPage page; + + static Future show(BuildContext context, DeletedPage page) { + return showShadDialog( + context: context, + barrierDismissible: false, + builder: (context) => DeletedPageDialog(page: page), + ); + } + + @override + ConsumerState createState() => _DeletedPageDialogState(); +} + +class _DeletedPageDialogState extends ConsumerState { + bool _isRestoring = false; + bool _isLeaving = false; + + /// The server can no longer restore the page; discarding is all that is + /// left. + bool _isGone = false; + String? _error; + + bool get _isBusy => _isRestoring || _isLeaving; + + Future _restore() async { + setState(() { + _isRestoring = true; + _error = null; + }); + final outcome = await ref + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + if (!mounted) return; + if (outcome == DeletedPageRestore.restored) { + Navigator.of(context).pop(); + return; + } + setState(() { + _isRestoring = false; + _isGone = outcome == DeletedPageRestore.gone; + _error = _isGone + ? null + : 'Could not confirm the page was restored. Check your ' + 'connection and try again, or discard your changes.'; + }); + } + + Future _discard() async { + setState(() { + _isLeaving = true; + _error = null; + }); + final left = + await ref.read(strategyPageSessionProvider.notifier).leaveDeletedPage(); + if (!mounted) return; + if (left) { + Navigator.of(context).pop(); + return; + } + setState(() { + _isLeaving = false; + _error = 'Could not leave this page yet: changes to it are still being ' + 'sent, or another page could not load. Try again in a moment.'; + }); + } + + @override + Widget build(BuildContext context) { + // Leaving the strategy resets the session; nothing is left to read. + ref.listen(strategyPageSessionProvider.select((state) => state.deletedPage), + (_, next) { + if (next == null && !_isBusy) Navigator.of(context).pop(); + }); + final theme = ShadTheme.of(context); + final name = widget.page.name; + + return ShadDialog.alert( + title: const Text('This page was deleted'), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isGone + ? '“$name” can no longer be restored, so your latest changes ' + 'to it could not be saved.' + : '“$name” was deleted while you were editing it. Restore it ' + 'to keep your latest changes, or discard them.', + ), + if (_error != null) ...[ + const SizedBox(height: 8), + Text( + _error!, + style: TextStyle(color: theme.colorScheme.destructive), + ), + ], + ], + ), + actions: [ + if (_isGone) + ShadButton( + onPressed: _isBusy ? null : _discard, + child: const Text('Discard changes'), + ) + else ...[ + ShadButton.secondary( + onPressed: _isBusy ? null : _discard, + child: const Text('Discard changes'), + ), + ShadButton( + onPressed: _isBusy ? null : _restore, + child: Text(_isRestoring ? 'Restoring…' : 'Restore page'), + ), + ], + ], + ); + } +} diff --git a/lib/widgets/dialogs/export_video_dialog.dart b/lib/widgets/dialogs/export_video_dialog.dart index 3bc5446a..80069230 100644 --- a/lib/widgets/dialogs/export_video_dialog.dart +++ b/lib/widgets/dialogs/export_video_dialog.dart @@ -1,25 +1,30 @@ import 'dart:async'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive_ce/hive.dart'; import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/sort_index_order.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; -import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/view_cone_geometry_provider.dart'; import 'package:icarus/providers/navigation_geometry_provider.dart'; import 'package:icarus/page_transition/navigation_geometry_map.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:icarus/services/video_export/browser_video_sink.dart'; import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; +import 'package:icarus/services/video_export/ffmpeg_video_sink.dart'; +import 'package:icarus/services/video_export/video_export_source.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:icarus/services/video_export/video_exporter.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/view_cone/vision_geometry.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -40,25 +45,33 @@ class _ExportVideoDialogState extends ConsumerState { /// descendant (same idiom as UploadImageDialog). static const double _contentWidth = _dialogWidth - 48; - List _pages = const []; + List _pages = const []; final Set _selectedPageIds = {}; double _stepDurationSeconds = 3.0; VideoExportQuality _quality = VideoExportQuality.social; + /// Saving, syncing, and loading images, before any frame renders. + bool _preparing = false; + bool _cancelRequested = false; VideoExporter? _exporter; double _progress = 0; String _progressLabel = ''; bool _exportRunning = false; - bool get _isExporting => _exporter != null; + bool get _isExporting => _preparing || _exporter != null; @override void dispose() { // Closing the dialog mid-export must not leave frames rendering and - // ffmpeg encoding in the background. - _exporter?.cancel(); + // the encoder running in the background. + _cancel(); super.dispose(); } + void _cancel() { + _cancelRequested = true; + _exporter?.cancel(); + } + @override void initState() { super.initState(); @@ -66,11 +79,7 @@ class _ExportVideoDialogState extends ConsumerState { .read(appPreferencesProvider) .videoExportStepDurationSeconds .clamp(1.0, 15.0); - final doc = Hive.box( - HiveBoxNames.strategiesBox, - ).get(ref.read(strategyProvider).id); - _pages = [...?doc?.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); + _pages = videoExportPageChoices(ref); _selectedPageIds.addAll(_pages.map((p) => p.id)); } @@ -90,7 +99,8 @@ class _ExportVideoDialogState extends ConsumerState { // The root container outlives this dialog, so cleanup after a // mid-export dismissal can still reach the live providers. final container = ProviderScope.containerOf(context, listen: false); - + final quality = _quality; + final pageIds = {..._selectedPageIds}; final stepDuration = Duration( milliseconds: (_stepDurationSeconds * 1000).round(), ); @@ -99,83 +109,114 @@ class _ExportVideoDialogState extends ConsumerState { .read(appPreferencesProvider.notifier) .setVideoExportStepDurationSeconds(_stepDurationSeconds), ); - - final ffmpegBinary = await FfmpegVideoEncoder.resolveBinary(); - if (ffmpegBinary == null) { - Settings.showToast( - message: - 'Video encoder not found. Reinstalling Icarus should fix this.', - backgroundColor: Settings.tacticalVioletTheme.destructive, + final fileName = + "${sanitizeStrategyFileName(ref.read(strategyProvider).stratName ?? "new video")}.mp4"; + + // Where the video goes is settled first, so a dismissed save dialog or + // a browser that cannot encode video costs nothing. + final VideoFrameSink sink; + if (kIsWeb) { + final totalSeconds = VideoExporter.plannedDurationSeconds( + pageCount: pageIds.length, + stepSeconds: stepDuration.inMilliseconds / 1000, + fps: quality.fps, ); - return; - } - - final selectedOutputPath = await FilePicker.platform.saveFile( - type: FileType.custom, - dialogTitle: 'Please select an output file:', - fileName: - "${StrategyProvider.sanitizeFileName(ref.read(strategyProvider).stratName ?? "new video")}.mp4", - allowedExtensions: ['mp4'], - ); - if (selectedOutputPath == null || !mounted) return; - final outputPath = ensureMp4Extension(selectedOutputPath); - - final strategyId = ref.read(strategyProvider).id; - await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); - final doc = Hive.box( - HiveBoxNames.strategiesBox, - ).get(strategyId); - if (doc == null || !mounted) return; - - // Resolve pages from the freshly saved document — the dialog's initial - // snapshot may predate unsaved edits on the active page. - final selectedPages = ([...doc.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex))) - .where((p) => _selectedPageIds.contains(p.id)) - .toList(); - if (selectedPages.isEmpty) return; - - final mapState = ref.read(mapProvider); - final requireNavigation = - ref.read(worldGeometryEnabledProvider(mapState.currentMap)); - VisionGeometryMap? geometry; - NavigationGeometryMap? navigation; - try { - if (requireNavigation) { - navigation = await ref - .read(navigationGeometryProvider(mapState.currentMap).future); - } else { - geometry = await ref - .read(viewConeGeometryProvider(mapState.currentMap).future); + if (!await browserCanEncodeVideo( + quality, + totalSeconds: totalSeconds, + )) { + Settings.showToast( + message: 'This browser cannot export video. Try the latest Chrome ' + 'or Edge, or the desktop app.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + sink = createBrowserVideoSink(quality); + } else { + final ffmpegBinary = await FfmpegVideoEncoder.resolveBinary(); + if (ffmpegBinary == null) { + Settings.showToast( + message: + 'Video encoder not found. Reinstalling Icarus should fix this.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; } - } on Object { - // The provider reports the failure. The exporter rejects movement on - // enabled maps when their required navigation data is unavailable. + final selectedOutputPath = await FilePicker.platform.saveFile( + type: FileType.custom, + dialogTitle: 'Please select an output file:', + fileName: fileName, + allowedExtensions: ['mp4'], + ); + if (selectedOutputPath == null) return; + sink = FfmpegVideoSink( + binary: ffmpegBinary, + outputPath: ensureMp4Extension(selectedOutputPath), + quality: quality, + ); } if (!mounted) return; - final exporter = VideoExporter( - strategy: doc, - strategyState: ref.read(strategyProvider), - mapState: mapState, - geometry: geometry, - navigation: navigation, - requireNavigation: requireNavigation, - ); setState(() { - _exporter = exporter; + _preparing = true; + _cancelRequested = false; _progress = 0; - _progressLabel = 'Preparing'; + _progressLabel = 'Saving your changes'; }); - - CoordinateSystem.instance.setIsScreenshot(true); + CaptureImages? images; try { - await exporter.export( + final source = await loadVideoExportSource( + ref, + pageIds: pageIds, + isCancelled: () => _cancelRequested, + ); + images = source.images; + if (_cancelRequested) throw VideoExportCancelled(); + final selectedPages = ([...source.strategy.pages] + ..sortBySortIndex((item) => item.sortIndex)) + .where((p) => pageIds.contains(p.id)) + .toList(); + + final mapState = ref.read(mapProvider); + final requireNavigation = + ref.read(worldGeometryEnabledProvider(mapState.currentMap)); + VisionGeometryMap? geometry; + NavigationGeometryMap? navigation; + try { + if (requireNavigation) { + navigation = await ref + .read(navigationGeometryProvider(mapState.currentMap).future); + } else { + geometry = await ref + .read(viewConeGeometryProvider(mapState.currentMap).future); + } + } on Object { + // The provider reports the failure. The exporter rejects movement on + // enabled maps when their required navigation data is unavailable. + } + if (_cancelRequested) throw VideoExportCancelled(); + + final exporter = VideoExporter( + strategy: source.strategy, + strategyState: ref.read(strategyProvider), + mapState: mapState, + geometry: geometry, + imageSources: images.sources, + navigation: navigation, + requireNavigation: requireNavigation, + ); + setState(() { + _exporter = exporter; + _progressLabel = 'Preparing'; + }); + + CoordinateSystem.instance.setIsScreenshot(true); + final video = await exporter.export( pages: selectedPages, stepDuration: stepDuration, - ffmpegBinary: ffmpegBinary, - outputPath: outputPath, - quality: _quality, + sink: sink, + quality: quality, onProgress: (fraction, label) { if (!mounted) return; setState(() { @@ -184,6 +225,18 @@ class _ExportVideoDialogState extends ConsumerState { }); }, ); + // A cancel that arrived while the export was wrapping up still wins. + if (_cancelRequested) throw VideoExportCancelled(); + // The browser sink hands the finished video back to be downloaded; + // the desktop one has already written it where the user chose. + if (video != null) { + await FilePicker.platform.saveFile( + type: FileType.custom, + fileName: fileName, + allowedExtensions: ['mp4'], + bytes: video, + ); + } unawaited( AnalyticsService.instance.capture( 'content_exported', @@ -191,8 +244,8 @@ class _ExportVideoDialogState extends ConsumerState { 'content_type': 'video', 'page_count': selectedPages.length, 'step_duration_seconds': _stepDurationSeconds, - 'quality': _quality.name, - 'fps': _quality.fps, + 'quality': quality.name, + 'fps': quality.fps, }, ), ); @@ -208,12 +261,41 @@ class _ExportVideoDialogState extends ConsumerState { message: 'Video export cancelled.', backgroundColor: Settings.tacticalVioletTheme.primary, ); - } on Object catch (error) { + } on VideoExportNotSynced catch (error) { + Settings.showToast( + message: error.userMessage, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } on VideoExportPagesChanged catch (error) { Settings.showToast( - message: 'Video export failed: $error', + message: error.userMessage, backgroundColor: Settings.tacticalVioletTheme.destructive, ); + if (mounted) { + setState(() { + _pages = videoExportPageChoices(ref); + _selectedPageIds.retainAll({for (final page in _pages) page.id}); + }); + } + } on CaptureImagesUnavailable catch (error) { + Settings.showToast( + message: error.userMessage, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } on Object catch (error) { + Settings.showToast( + // Closing the dialog mid-preparation can fail the work it cut + // short; the user asked to stop, so that is what they hear. + message: _cancelRequested + ? 'Video export cancelled.' + : 'Video export failed: $error', + backgroundColor: _cancelRequested + ? Settings.tacticalVioletTheme.primary + : Settings.tacticalVioletTheme.destructive, + ); } finally { + await sink.close(); + images?.release(); CoordinateSystem.instance.setIsScreenshot(false); container.read(screenshotProvider.notifier).setIsScreenShot(false); container @@ -221,6 +303,7 @@ class _ExportVideoDialogState extends ConsumerState { .rebuildAllPaths(CoordinateSystem.instance); if (mounted) { setState(() { + _preparing = false; _exporter = null; }); } @@ -270,7 +353,7 @@ class _ExportVideoDialogState extends ConsumerState { title: const Text('Exporting video'), actions: [ ShadButton.destructive( - onPressed: () => _exporter?.cancel(), + onPressed: _cancel, child: const Text('Cancel export'), ), ], @@ -299,6 +382,17 @@ class _ExportVideoDialogState extends ConsumerState { ), const SizedBox(height: 12), ShadProgress(value: _progress, minHeight: 8), + // A hidden tab stops painting frames, which stalls the render. + if (kIsWeb) ...[ + const SizedBox(height: 12), + Text( + 'Keep this tab open until the video downloads.', + style: TextStyle( + fontSize: 12, + color: colors.mutedForeground, + ), + ), + ], ], ), ), @@ -578,7 +672,7 @@ class _PagesPanel extends StatelessWidget { required this.onToggle, }); - final List pages; + final List pages; final Set selectedPageIds; final ValueChanged onToggle; diff --git a/lib/widgets/dialogs/lineup_panel_dialog.dart b/lib/widgets/dialogs/lineup_panel_dialog.dart index c17b8047..6b0e4cee 100644 --- a/lib/widgets/dialogs/lineup_panel_dialog.dart +++ b/lib/widgets/dialogs/lineup_panel_dialog.dart @@ -1,18 +1,15 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/action_provider.dart'; -import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:icarus/widgets/line_up_media_carousel.dart'; -import 'package:path/path.dart' as path; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Opens the lineup panel for a landing spot or an origin: every lineup that @@ -77,7 +74,6 @@ class LineUpPanelDialog extends ConsumerStatefulWidget { class _LineUpPanelDialogState extends ConsumerState { String? _selectedLinkId; final Object _hoverOwnerToken = Object(); - Directory? _imageFolder; ProviderContainer? _container; @override @@ -86,13 +82,20 @@ class _LineUpPanelDialogState extends ConsumerState { _container ??= ProviderScope.containerOf(context, listen: false); } + /// The spot the panel is for, which names its lineup group. + String get _spotId => widget.landingId ?? widget.originId!; + @override void initState() { super.initState(); _selectedLinkId = widget.initialLinkId; - PlacedImageProvider.getImageFolder(ref.read(strategyProvider).id) - .then((dir) { - if (mounted) setState(() => _imageFolder = dir); + // Having the panel open counts as editing its lineups (see + // myLineupEditingProvider); providers change after this frame. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ref.read(openLineUpItemsProvider.notifier).open(_hoverOwnerToken, { + _spotId, + }); }); } @@ -104,6 +107,9 @@ class _LineUpPanelDialogState extends ConsumerState { container .read(hoveredLineUpTargetProvider.notifier) .clearIfOwned(_hoverOwnerToken); + container + .read(openLineUpItemsProvider.notifier) + .close(_hoverOwnerToken); }); } super.dispose(); @@ -156,12 +162,7 @@ class _LineUpPanelDialogState extends ConsumerState { ); controller.dispose(); if (name == null || name == link.name) return; - ref.read(actionProvider.notifier).performTransaction( - groups: const [ActionGroup.lineUp], - mutation: () { - ref.read(lineUpProvider.notifier).updateLink(link.copyWith(name: name)); - }, - ); + ref.read(lineUpProvider.notifier).updateLink(link.copyWith(name: name)); } @override @@ -211,7 +212,14 @@ class _LineUpPanelDialogState extends ConsumerState { autofocus: true, child: ShadDialog( title: Text(title), - description: Text(subtitle), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle), + LineUpEditorsNotice(itemId: _spotId, lineups: true), + ], + ), constraints: BoxConstraints(maxWidth: bodyWidth + 48), child: SizedBox( width: bodyWidth, @@ -238,7 +246,6 @@ class _LineUpPanelDialogState extends ConsumerState { .type] ?.name ?? '', - imageFolder: _imageFolder, isSelected: link.id == selected.id, onTap: () => setState(() => _selectedLinkId = link.id), @@ -299,7 +306,6 @@ class _LineUpRow extends StatefulWidget { required this.link, required this.label, required this.agentName, - required this.imageFolder, required this.isSelected, required this.onTap, required this.onHoverEnter, @@ -312,7 +318,6 @@ class _LineUpRow extends StatefulWidget { final LineUpLink link; final String label; final String agentName; - final Directory? imageFolder; final bool isSelected; final VoidCallback onTap; final VoidCallback onHoverEnter; @@ -333,14 +338,6 @@ class _LineUpRowState extends State<_LineUpRow> { const theme = Settings.tacticalVioletTheme; final firstImage = widget.link.images.isEmpty ? null : widget.link.images.first; - final file = firstImage == null || widget.imageFolder == null - ? null - : File( - path.join( - widget.imageFolder!.path, - firstImage.id + firstImage.fileExtension, - ), - ); return ShadContextMenuRegion( items: [ @@ -389,16 +386,7 @@ class _LineUpRowState extends State<_LineUpRow> { child: SizedBox( width: 72, height: 44, - child: file != null && file.existsSync() - ? Image.file(file, fit: BoxFit.cover) - : Container( - color: theme.secondary, - child: Icon( - LucideIcons.image, - size: 18, - color: theme.mutedForeground, - ), - ), + child: _LineUpThumbnail(image: firstImage), ), ), const SizedBox(width: 12), @@ -437,3 +425,38 @@ class _LineUpRowState extends State<_LineUpRow> { ); } } + +/// The first image of a lineup, or a muted glyph when it has none to show. +class _LineUpThumbnail extends ConsumerWidget { + const _LineUpThumbnail({required this.image}); + + final SimpleImageData? image; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final image = this.image; + final imageProvider = image == null + ? null + : watchStrategyImageSource( + ref, + (id: image.id, fileExtension: image.fileExtension), + ).imageProvider; + if (imageProvider != null) { + return Image( + key: ValueKey(image!.id), + image: imageProvider, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + const theme = Settings.tacticalVioletTheme; + return Container( + color: theme.secondary, + child: Icon( + LucideIcons.image, + size: 18, + color: theme.mutedForeground, + ), + ); + } +} diff --git a/lib/widgets/dialogs/recently_deleted_dialog.dart b/lib/widgets/dialogs/recently_deleted_dialog.dart new file mode 100644 index 00000000..11844716 --- /dev/null +++ b/lib/widgets/dialogs/recently_deleted_dialog.dart @@ -0,0 +1,294 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// The cloud strategy's deleted pages that can still be restored: who +/// deleted each and when, how long it has left, and Restore, which puts it +/// back where it was with everything on it. +class RecentlyDeletedDialog extends ConsumerStatefulWidget { + const RecentlyDeletedDialog({super.key, required this.strategyPublicId}); + + final String strategyPublicId; + + static Future show(BuildContext context, String strategyPublicId) { + return showShadDialog( + context: context, + builder: (context) => + RecentlyDeletedDialog(strategyPublicId: strategyPublicId), + ); + } + + @override + ConsumerState createState() => + _RecentlyDeletedDialogState(); +} + +enum _RowState { idle, restoring, failed, gone } + +class _RecentlyDeletedDialogState extends ConsumerState { + List? _pages; + bool _loadFailed = false; + final Map _rows = {}; + + /// Keeps the times shown current while the dialog stays open, and takes + /// Restore away from a page whose time runs out meanwhile. + late final Timer _clock; + + @override + void initState() { + super.initState(); + _clock = Timer.periodic( + const Duration(minutes: 1), + (_) => setState(() {}), + ); + _load(); + } + + @override + void dispose() { + _clock.cancel(); + super.dispose(); + } + + Future _load() async { + setState(() => _loadFailed = false); + try { + final pages = await ref + .read(convexStrategyRepositoryProvider) + .listTrashedPages(widget.strategyPublicId); + if (!mounted) return; + setState(() => _pages = pages); + } catch (_) { + if (!mounted) return; + setState(() => _loadFailed = true); + } + } + + Future _restore(TrashedPage page) async { + setState(() => _rows[page.pageId] = _RowState.restoring); + final outcome = await ref + .read(strategyPageSessionProvider.notifier) + .restorePageFromTrash(page.pageId); + if (!mounted) return; + setState(() { + switch (outcome) { + case DeletedPageRestore.restored: + _rows.remove(page.pageId); + _pages = [ + for (final other in _pages ?? const []) + if (other.pageId != page.pageId) other, + ]; + case DeletedPageRestore.gone: + _rows[page.pageId] = _RowState.gone; + case DeletedPageRestore.failed: + _rows[page.pageId] = _RowState.failed; + } + }); + } + + @override + Widget build(BuildContext context) { + return ShadDialog( + title: const Text('Recently deleted'), + description: const Text( + 'Deleted pages stay here for $pageTrashRetentionDays days, then they ' + 'are gone for good.', + ), + child: SizedBox( + width: 440, + child: _body(ShadTheme.of(context)), + ), + ); + } + + Widget _body(ShadThemeData theme) { + final muted = theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w400, + ); + if (_loadFailed) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded( + child: Text( + 'Could not load deleted pages. Check your connection and ' + 'try again.', + style: muted, + ), + ), + const SizedBox(width: 12), + ShadButton.secondary( + size: ShadButtonSize.sm, + onPressed: _load, + child: const Text('Try again'), + ), + ], + ), + ); + } + final pages = _pages; + if (pages == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text('Loading…', style: muted), + ); + } + if (pages.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.archiveRestore, + size: 20, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(height: 8), + Text('No deleted pages.', style: muted), + ], + ), + ), + ); + } + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 360), + child: ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: pages.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final page = pages[index]; + return _TrashedPageRow( + page: page, + state: _rows[page.pageId] ?? _RowState.idle, + onRestore: () => _restore(page), + ); + }, + ), + ); + } +} + +class _TrashedPageRow extends StatelessWidget { + const _TrashedPageRow({ + required this.page, + required this.state, + required this.onRestore, + }); + + final TrashedPage page; + final _RowState state; + final VoidCallback onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final now = clock.now(); + // Past its time here too, as when the server says so. + final gone = state == _RowState.gone || !page.restorableUntil.isAfter(now); + final error = gone + ? 'It can no longer be restored.' + : state == _RowState.failed + ? 'Could not confirm the restore. Check your connection and try ' + 'again.' + : null; + // Type roles from DESIGN.md: title for the name, label for the rest. + final label = theme.textTheme.small.copyWith( + fontSize: 12, + fontWeight: FontWeight.w600, + ); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: theme.colorScheme.border), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + page.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.foreground, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + Text( + gone + ? deletedLabel(page, now) + : '${deletedLabel(page, now)} · ' + '${timeLeftLabel(page, now)}', + style: label.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + if (error != null) ...[ + const SizedBox(height: 8), + Text( + error, + style: label.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + ], + ), + ), + if (!gone) ...[ + const SizedBox(width: 12), + ShadButton.secondary( + size: ShadButtonSize.sm, + onPressed: state == _RowState.restoring ? null : onRestore, + child: Text( + state == _RowState.restoring ? 'Restoring…' : 'Restore', + ), + ), + ], + ], + ), + ); + } +} + +/// "Deleted by Sam, 2 days ago", "Deleted by you, 5 minutes ago", or +/// "Deleted 2 days ago" when who deleted it is not known. +@visibleForTesting +String deletedLabel(TrashedPage page, DateTime now) { + final by = page.deletedByYou ? 'you' : page.deletedByName; + final when = _ago(now.difference(page.deletedAt)); + return by == null ? 'Deleted $when' : 'Deleted by $by, $when'; +} + +/// "28 days left", "1 day left", "Less than a day left". +@visibleForTesting +String timeLeftLabel(TrashedPage page, DateTime now) { + final days = page.restorableUntil.difference(now).inDays; + if (days < 1) return 'Less than a day left'; + return days == 1 ? '1 day left' : '$days days left'; +} + +String _ago(Duration elapsed) { + String count(int n, String unit) => n == 1 ? '1 $unit' : '$n ${unit}s'; + if (elapsed.inMinutes < 1) return 'just now'; + if (elapsed.inHours < 1) return '${count(elapsed.inMinutes, 'minute')} ago'; + if (elapsed.inDays < 1) return '${count(elapsed.inHours, 'hour')} ago'; + return '${count(elapsed.inDays, 'day')} ago'; +} diff --git a/lib/widgets/dialogs/share_links_dialog.dart b/lib/widgets/dialogs/share_links_dialog.dart new file mode 100644 index 00000000..d87316c6 --- /dev/null +++ b/lib/widgets/dialogs/share_links_dialog.dart @@ -0,0 +1,886 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/share/current_share_origin.dart'; +import 'package:icarus/share/share_link_copy.dart'; +import 'package:icarus/share/share_link_format.dart'; +import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Headline like: Share "My Strategy Name" (`"` in [name] become `'`). +String _shareDialogHeadline(String name) { + final safe = name.replaceAll('"', "'"); + return 'Share "$safe"'; +} + +const _stateSwitchDuration = Duration(milliseconds: 180); +const _hoverDuration = Duration(milliseconds: 120); +const _copiedFlashDuration = Duration(milliseconds: 1200); + +String _relativeTime(DateTime time) { + final diff = DateTime.now().difference(time); + if (diff.inMinutes < 1) return 'just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 30) return '${diff.inDays}d ago'; + return '${time.year}-${time.month.toString().padLeft(2, '0')}' + '-${time.day.toString().padLeft(2, '0')}'; +} + +class ShareLinksDialog extends ConsumerStatefulWidget { + const ShareLinksDialog({ + super.key, + required this.targetType, + required this.targetPublicId, + required this.title, + }); + + final String targetType; + final String targetPublicId; + final String title; + + @override + ConsumerState createState() => _ShareLinksDialogState(); +} + +Future showAddSharedItemDialog(BuildContext context) { + return showShadDialog( + context: context, + builder: (_) => const AddSharedItemDialog(), + ); +} + +enum _LinksStatus { loading, error, ready } + +class _ShareLinksDialogState extends ConsumerState { + List _links = const []; + _LinksStatus _status = _LinksStatus.loading; + bool _isCreating = false; + String? _disablingToken; + String _selectedRole = 'viewer'; + + int get _activeLinkCount => _links.where((link) => !link.isRevoked).length; + + Future _loadLinks() async { + setState(() => _status = _LinksStatus.loading); + try { + final links = + await ref.read(convexStrategyRepositoryProvider).listShareLinks( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + ); + if (!mounted) return; + setState(() { + _links = links; + _status = _LinksStatus.ready; + }); + } catch (_) { + if (!mounted) return; + setState(() => _status = _LinksStatus.error); + } + } + + @override + void initState() { + super.initState(); + _loadLinks(); + } + + Future _createLink() async { + setState(() => _isCreating = true); + final token = generateIcarusShareCode(); + try { + await ref.read(convexStrategyRepositoryProvider).createShareLink( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + token: token, + role: _selectedRole, + ); + await Clipboard.setData(ClipboardData( + text: buildIcarusShareLink(token, origin: currentShareOrigin()))); + Settings.showToast( + message: 'Share link copied to clipboard.', + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + await _loadLinks(); + } catch (_) { + Settings.showToast( + message: 'Failed to create share link.', + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } finally { + if (mounted) { + setState(() => _isCreating = false); + } + } + } + + Future _disableLink(String token) async { + final confirmed = await ConfirmAlertDialog.show( + context: context, + title: ShareLinkCopy.disableTitle, + content: ShareLinkCopy.disableDescription, + confirmText: ShareLinkCopy.disableAction, + isDestructive: true, + ); + if (!confirmed || !mounted) { + return; + } + setState(() => _disablingToken = token); + try { + await ref.read(convexStrategyRepositoryProvider).revokeShareLink( + targetType: widget.targetType, + targetPublicId: widget.targetPublicId, + token: token, + ); + await _loadLinks(); + } catch (_) { + Settings.showToast( + message: ShareLinkCopy.disableFailure, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } finally { + if (mounted) { + setState(() => _disablingToken = null); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return ShadDialog( + title: Text( + _shareDialogHeadline(widget.title), + softWrap: true, + ), + description: Text( + ShareLinkCopy.dialogDescription( + widget.targetType, + // Links made on web point at the web app (see currentShareOrigin). + opensInBrowser: kIsWeb, + ), + ), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Done'), + ), + ], + child: SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionLabel(label: 'New link'), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ShadSelect( + key: const ValueKey('share-link-role-select'), + initialValue: _selectedRole, + selectedOptionBuilder: (context, value) => Text( + value == 'editor' ? 'Can edit' : 'View only', + ), + options: const [ + ShadOption( + value: 'viewer', + child: _RoleOptionLabel( + label: 'View only', + description: 'Can open and view every page', + ), + ), + ShadOption( + value: 'editor', + child: _RoleOptionLabel( + label: 'Can edit', + description: 'Can change pages, drawings, and media', + ), + ), + ], + onChanged: (value) { + if (value != null) { + setState(() => _selectedRole = value); + } + }, + ), + ), + const SizedBox(width: 8), + ShadButton( + key: const ValueKey('share-link-create-and-copy'), + onPressed: _isCreating ? null : _createLink, + leading: _isCreating + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primaryForeground, + ), + ) + : const Icon(LucideIcons.link, size: 16), + child: Text(_isCreating ? 'Creating…' : 'Create & copy'), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + const _SectionLabel(label: 'Active links'), + if (_status == _LinksStatus.ready && _activeLinkCount > 0) ...[ + const SizedBox(width: 8), + ShadBadge.secondary( + child: Text('$_activeLinkCount'), + ), + ], + ], + ), + const SizedBox(height: 10), + AnimatedSize( + duration: _stateSwitchDuration, + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: _stateSwitchDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: _buildLinksBody(theme), + ), + ), + ], + ), + ), + ); + } + + Widget _buildLinksBody(ShadThemeData theme) { + switch (_status) { + case _LinksStatus.loading: + return const _LinkListPlaceholder(key: ValueKey('loading')); + case _LinksStatus.error: + return _LinkListMessage( + key: const ValueKey('error'), + icon: LucideIcons.circleAlert, + message: "Couldn't load share links.", + action: ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: _loadLinks, + leading: const Icon(LucideIcons.refreshCw, size: 14), + child: const Text('Retry'), + ), + ); + case _LinksStatus.ready: + if (_links.isEmpty) { + return const _LinkListMessage( + key: ValueKey('empty'), + icon: LucideIcons.link2, + message: 'No links yet. Create one above to invite collaborators.', + ); + } + return ConstrainedBox( + key: const ValueKey('links'), + constraints: const BoxConstraints(maxHeight: 280), + child: ListView.separated( + shrinkWrap: true, + itemCount: _links.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final link = _links[index]; + return _ShareLinkTile( + link: link, + isDisabling: _disablingToken == link.token, + onDisable: () => _disableLink(link.token), + ); + }, + ), + ); + } + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Text( + label, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ); + } +} + +class _RoleOptionLabel extends StatelessWidget { + const _RoleOptionLabel({ + required this.label, + required this.description, + }); + + final String label; + final String description; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(height: 2), + Text( + description, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 11, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} + +class _RoleBadge extends StatelessWidget { + const _RoleBadge({required this.role, required this.isRevoked}); + + final String role; + final bool isRevoked; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final Color background; + final Color foreground; + final String label; + if (isRevoked) { + background = theme.colorScheme.destructive.withValues(alpha: 0.14); + foreground = theme.colorScheme.destructive; + label = ShareLinkCopy.disabledStatus; + } else if (role == 'editor') { + background = theme.colorScheme.primary.withValues(alpha: 0.16); + foreground = theme.colorScheme.primary; + label = 'EDIT'; + } else { + background = theme.colorScheme.muted; + foreground = theme.colorScheme.mutedForeground; + label = 'VIEW'; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + label, + style: TextStyle( + color: foreground, + fontSize: 10, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + ), + ); + } +} + +class _ShareLinkTile extends StatefulWidget { + const _ShareLinkTile({ + required this.link, + required this.isDisabling, + required this.onDisable, + }); + + final ShareLinkSummary link; + final bool isDisabling; + final VoidCallback onDisable; + + @override + State<_ShareLinkTile> createState() => _ShareLinkTileState(); +} + +class _ShareLinkTileState extends State<_ShareLinkTile> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final link = widget.link; + final url = buildIcarusShareLink(link.token, origin: currentShareOrigin()); + + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: AnimatedContainer( + duration: _hoverDuration, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all( + color: _hovered && !link.isRevoked + ? theme.colorScheme.ring + : theme.colorScheme.border, + ), + borderRadius: theme.radius, + ), + child: AnimatedOpacity( + duration: _hoverDuration, + opacity: link.isRevoked ? 0.6 : 1, + child: Row( + children: [ + _RoleBadge(role: link.role, isRevoked: link.isRevoked), + const SizedBox(width: 10), + Expanded( + child: Tooltip( + message: url, + waitDuration: const Duration(milliseconds: 400), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SelectionArea( + child: Text( + link.token, + style: theme.textTheme.small.copyWith( + fontFamily: 'monospace', + fontSize: 12, + height: 1.3, + decoration: link.isRevoked + ? TextDecoration.lineThrough + : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 2), + Text( + 'Created ${_relativeTime(link.createdAt)}', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 11, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + _CopyIconButton( + buttonKey: ValueKey('share-link-copy-link-${link.token}'), + tooltip: 'Copy link', + icon: LucideIcons.link, + enabled: !link.isRevoked, + textToCopy: url, + toastMessage: 'Share link copied to clipboard.', + ), + _CopyIconButton( + buttonKey: ValueKey('share-link-copy-code-${link.token}'), + tooltip: 'Copy code', + icon: LucideIcons.hash, + enabled: !link.isRevoked, + textToCopy: link.token, + toastMessage: 'Share code copied to clipboard.', + ), + Semantics( + label: link.isRevoked + ? ShareLinkCopy.disabledTooltip + : ShareLinkCopy.disableTooltip, + button: true, + enabled: !link.isRevoked && !widget.isDisabling, + onTap: link.isRevoked || widget.isDisabling + ? null + : widget.onDisable, + excludeSemantics: true, + child: Tooltip( + message: link.isRevoked + ? ShareLinkCopy.disabledTooltip + : ShareLinkCopy.disableTooltip, + child: ShadButton.ghost( + key: ValueKey('share-link-disable-${link.token}'), + size: ShadButtonSize.sm, + onPressed: link.isRevoked || widget.isDisabling + ? null + : widget.onDisable, + child: widget.isDisabling + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.mutedForeground, + ), + ) + : Icon( + LucideIcons.link2Off, + size: 16, + color: link.isRevoked + ? theme.colorScheme.mutedForeground + : theme.colorScheme.destructive, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CopyIconButton extends StatefulWidget { + const _CopyIconButton({ + required this.buttonKey, + required this.tooltip, + required this.icon, + required this.enabled, + required this.textToCopy, + required this.toastMessage, + }); + + final Key buttonKey; + final String tooltip; + final IconData icon; + final bool enabled; + final String textToCopy; + final String toastMessage; + + @override + State<_CopyIconButton> createState() => _CopyIconButtonState(); +} + +class _CopyIconButtonState extends State<_CopyIconButton> { + bool _copied = false; + Timer? _resetTimer; + + @override + void dispose() { + _resetTimer?.cancel(); + super.dispose(); + } + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.textToCopy)); + if (!mounted) return; + setState(() => _copied = true); + _resetTimer?.cancel(); + _resetTimer = Timer(_copiedFlashDuration, () { + if (mounted) { + setState(() => _copied = false); + } + }); + Settings.showToast( + message: widget.toastMessage, + backgroundColor: Settings.tacticalVioletTheme.primary, + ); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Semantics( + label: widget.tooltip, + button: true, + enabled: widget.enabled, + onTap: widget.enabled ? _copy : null, + excludeSemantics: true, + child: Tooltip( + message: widget.tooltip, + child: ShadButton.ghost( + key: widget.buttonKey, + size: ShadButtonSize.sm, + onPressed: widget.enabled ? _copy : null, + child: AnimatedSwitcher( + duration: _hoverDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: _copied + ? Icon( + LucideIcons.check, + key: const ValueKey('check'), + size: 16, + color: theme.colorScheme.primary, + ) + : Icon( + widget.icon, + key: const ValueKey('idle'), + size: 16, + color: widget.enabled + ? theme.colorScheme.foreground + : theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ); + } +} + +class _LinkListMessage extends StatelessWidget { + const _LinkListMessage({ + super.key, + required this.icon, + required this.message, + this.action, + }); + + final IconData icon; + final String message; + final Widget? action; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 20, color: theme.colorScheme.mutedForeground), + const SizedBox(height: 8), + Text( + message, + style: theme.textTheme.muted, + textAlign: TextAlign.center, + ), + if (action != null) ...[ + const SizedBox(height: 8), + action!, + ], + ], + ), + ); + } +} + +class _LinkListPlaceholder extends StatefulWidget { + const _LinkListPlaceholder({super.key}); + + @override + State<_LinkListPlaceholder> createState() => _LinkListPlaceholderState(); +} + +class _LinkListPlaceholderState extends State<_LinkListPlaceholder> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.of(context).disableAnimations) { + _controller.stop(); + _controller.value = 1; + } else if (!_controller.isAnimating) { + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return FadeTransition( + opacity: Tween(begin: 0.45, end: 0.9).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ), + child: Column( + children: [ + for (var i = 0; i < 2; i++) ...[ + if (i > 0) const SizedBox(height: 8), + Container( + height: 54, + decoration: BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.4), + borderRadius: theme.radius, + ), + ), + ], + ], + ), + ); + } +} + +class AddSharedItemDialog extends ConsumerStatefulWidget { + const AddSharedItemDialog({super.key}); + + @override + ConsumerState createState() => + _AddSharedItemDialogState(); +} + +class _AddSharedItemDialogState extends ConsumerState { + final TextEditingController _controller = TextEditingController(); + bool _isSubmitting = false; + String? _errorText; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_isSubmitting) { + return; + } + final token = extractIcarusShareCode( + _controller.text, + currentOrigin: currentShareOrigin(), + ); + if (token == null || token.isEmpty) { + setState(() { + _errorText = _controller.text.trim().isEmpty + ? 'Paste a share link or code first.' + : "That doesn't look like an Icarus share link or code."; + }); + return; + } + setState(() { + _isSubmitting = true; + _errorText = null; + }); + final succeeded = + await ref.read(shareLinkControllerProvider.notifier).redeemToken(token); + if (!mounted) return; + setState(() => _isSubmitting = false); + if (succeeded) { + Navigator.of(context).pop(); + } else { + setState(() { + _errorText = "Couldn't add that item. The link may be revoked, " + 'or you may need to sign in.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final hasError = _errorText != null; + + return ShadDialog( + title: const Text('Add Shared Item'), + description: const Text( + 'Paste an Icarus share link or enter a share code to add it to ' + 'Shared with Me.', + ), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ShadButton( + onPressed: _isSubmitting ? null : _submit, + leading: _isSubmitting + ? SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primaryForeground, + ), + ) + : null, + child: Text(_isSubmitting ? 'Adding…' : 'Add'), + ), + ], + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextEditingShortcutScope( + child: ShadInput( + controller: _controller, + autofocus: true, + placeholder: Text( + '${currentShareOrigin()}/share/… or ICR-XXXX-XXXX-XXXX-XXXX', + ), + onSubmitted: (_) => _submit(), + onChanged: (_) { + if (_errorText != null) { + setState(() => _errorText = null); + } + }, + decoration: hasError + ? ShadDecoration( + border: ShadBorder.all( + color: theme.colorScheme.destructive, + ), + ) + : null, + ), + ), + AnimatedSize( + duration: _stateSwitchDuration, + curve: Curves.easeOutCubic, + alignment: Alignment.topLeft, + child: hasError + ? Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + LucideIcons.circleAlert, + size: 13, + color: theme.colorScheme.destructive, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _errorText!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + fontSize: 12, + ), + ), + ), + ], + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart index cb502aae..d8407aa9 100644 --- a/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart +++ b/lib/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart @@ -1,72 +1,138 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -class DeleteStrategyAlertDialog extends ConsumerWidget { +class DeleteStrategyAlertDialog extends ConsumerStatefulWidget { const DeleteStrategyAlertDialog({ super.key, required this.strategyID, required this.name, + required this.source, }); final String strategyID; final String name; + final StrategySource source; + + @override + ConsumerState createState() => + _DeleteStrategyAlertDialogState(); +} + +class _DeleteStrategyAlertDialogState + extends ConsumerState { + bool _isDeleting = false; + String? _failureMessage; + + Future _delete() async { + if (_isDeleting) return; + setState(() { + _isDeleting = true; + _failureMessage = null; + }); + + CloudLibraryActionResult? result; + try { + result = await ref.read(strategyProvider.notifier).deleteStrategy( + widget.strategyID, + source: widget.source, + ); + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to delete a strategy.', + source: 'strategy_dialog:delete', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't delete this strategy. Try again."; + }); + } + } finally { + if (mounted) setState(() => _isDeleting = false); + } + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + return; + } + if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't delete this strategy. Try again."); + } + } + @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { return ShadDialog.alert( title: Text.rich( TextSpan( children: [ const TextSpan(text: "Delete "), TextSpan( - text: name, + text: widget.name, style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), ), - description: Text.rich( - TextSpan( - children: [ - const TextSpan(text: "Are you sure you want to delete "), + description: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( TextSpan( - text: name, - style: const TextStyle(fontWeight: FontWeight.bold), + children: [ + const TextSpan(text: "Are you sure you want to delete "), + TextSpan( + text: widget.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: "? This action cannot be undone."), + ], + ), + ), + if (_failureMessage != null) ...[ + const SizedBox(height: 10), + Text( + _failureMessage!, + key: const ValueKey('delete-strategy-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), ), - const TextSpan(text: "? This action cannot be undone."), ], - ), + ], ), actions: [ ShadButton.secondary( - onPressed: () { - Navigator.of(context).pop(); - }, + onPressed: _isDeleting ? null : () => Navigator.of(context).pop(), child: const Text( "Cancel", ), ), ShadButton.destructive( - // padding: WidgetStateProperty.all( - // const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - - onPressed: () async { - await ref - .read(strategyProvider.notifier) - .deleteStrategy(strategyID); - - if (!context.mounted) return; - - Navigator.of(context).pop(); - }, - child: const Row( + key: const ValueKey('delete-strategy-confirm'), + onPressed: _isDeleting ? null : _delete, + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.trash2, color: Colors.white), - SizedBox(width: 5), + Icon( + LucideIcons.trash2, + color: Settings.tacticalVioletTheme.destructiveForeground, + ), + const SizedBox(width: 5), Text( - "Delete", - style: TextStyle(color: Colors.white), + _isDeleting ? 'Deleting...' : 'Delete', + style: TextStyle( + color: Settings.tacticalVioletTheme.destructiveForeground, + ), ), ], ), diff --git a/lib/widgets/dialogs/strategy/line_up_media_page.dart b/lib/widgets/dialogs/strategy/line_up_media_page.dart index f64b6251..599ddcb9 100644 --- a/lib/widgets/dialogs/strategy/line_up_media_page.dart +++ b/lib/widgets/dialogs/strategy/line_up_media_page.dart @@ -1,13 +1,9 @@ -import 'dart:io' show File, Directory; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/custom_text_field.dart'; -import 'package:path/path.dart' as path; import 'package:shadcn_ui/shadcn_ui.dart'; class LineupMediaPage extends ConsumerStatefulWidget { @@ -36,13 +32,6 @@ class LineupMediaPage extends ConsumerStatefulWidget { } class _LineupMediaPageState extends ConsumerState { - Directory? imageFolderPath; - - Future _setImageDirectory(String strategyID) async { - if (imageFolderPath != null) return; - imageFolderPath = await PlacedImageProvider.getImageFolder(strategyID); - } - @override void initState() { super.initState(); @@ -62,7 +51,10 @@ class _LineupMediaPageState extends ConsumerState { children: [ if (widget.header != null) widget.header!, if (widget.nameController != null) ...[ - const Text("Name", style: TextStyle(color: Colors.white)), + Text( + "Name", + style: TextStyle(color: Settings.tacticalVioletTheme.foreground), + ), const SizedBox(height: 8), CustomTextField( controller: widget.nameController!, @@ -71,14 +63,20 @@ class _LineupMediaPageState extends ConsumerState { ), const SizedBox(height: 24), ], - const Text("Youtube link", style: TextStyle(color: Colors.white)), + Text( + "Youtube link", + style: TextStyle(color: Settings.tacticalVioletTheme.foreground), + ), const SizedBox(height: 8), CustomTextField( controller: widget.youtubeLinkController, hintText: "Paste YouTube link here...", ), const SizedBox(height: 24), - const Text("Images", style: TextStyle(color: Colors.white)), + Text( + "Images", + style: TextStyle(color: Settings.tacticalVioletTheme.foreground), + ), const SizedBox(height: 8), Expanded( child: Container( @@ -92,9 +90,12 @@ class _LineupMediaPageState extends ConsumerState { widget.images.isEmpty ? _buildEmptyState() : _buildImageGrid(), ), ), - const Padding( - padding: EdgeInsets.symmetric(vertical: 8.0), - child: Text("Notes", style: TextStyle(color: Colors.white)), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + "Notes", + style: TextStyle(color: Settings.tacticalVioletTheme.foreground), + ), ), CustomTextField( hintText: "Add any notes here...", @@ -141,36 +142,25 @@ class _LineupMediaPageState extends ConsumerState { } Widget _buildImageGrid() { - return FutureBuilder( - future: _setImageDirectory(ref.read(strategyProvider).id), - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snapshot.hasError) { - return Center(child: Text('Error: ${snapshot.error}')); - } - - return GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 1, - ), - itemCount: widget.images.length + 2, // +1 for Add, +1 for Paste - itemBuilder: (context, index) { - if (index == widget.images.length + 1) { - return _buildAddButton(); - } - if (index == widget.images.length) { - return _buildPasteButton(); - } + return GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: widget.images.length + 2, // +1 for Add, +1 for Paste + itemBuilder: (context, index) { + if (index == widget.images.length + 1) { + return _buildAddButton(); + } + if (index == widget.images.length) { + return _buildPasteButton(); + } - return _buildImageTile(index); - }, - ); - }); + return _buildImageTile(index); + }, + ); } Widget _buildAddButton() { @@ -184,7 +174,10 @@ class _LineupMediaPageState extends ConsumerState { borderRadius: BorderRadius.circular(8), border: Border.all(color: Settings.tacticalVioletTheme.border), ), - child: const Icon(LucideIcons.plus, color: Colors.white), + child: Icon( + LucideIcons.plus, + color: Settings.tacticalVioletTheme.secondaryForeground, + ), ), ), ); @@ -201,14 +194,20 @@ class _LineupMediaPageState extends ConsumerState { borderRadius: BorderRadius.circular(8), border: Border.all(color: Settings.tacticalVioletTheme.border), ), - child: const Column( + child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(LucideIcons.clipboardPaste, color: Colors.white), - SizedBox(height: 4), + Icon( + LucideIcons.clipboardPaste, + color: Settings.tacticalVioletTheme.secondaryForeground, + ), + const SizedBox(height: 4), Text( "Paste", - style: TextStyle(color: Colors.white, fontSize: 12), + style: TextStyle( + color: Settings.tacticalVioletTheme.secondaryForeground, + fontSize: 12, + ), ), ], ), @@ -229,14 +228,20 @@ class _LineupMediaPageState extends ConsumerState { borderRadius: BorderRadius.circular(6), border: Border.all(color: Settings.tacticalVioletTheme.border), ), - child: const Row( + child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.clipboardPaste, color: Colors.white, size: 16), - SizedBox(width: 6), + Icon( + LucideIcons.clipboardPaste, + color: Settings.tacticalVioletTheme.secondaryForeground, + size: 16, + ), + const SizedBox(width: 6), Text( "Paste from clipboard", - style: TextStyle(color: Colors.white), + style: TextStyle( + color: Settings.tacticalVioletTheme.secondaryForeground, + ), ), ], ), @@ -246,20 +251,40 @@ class _LineupMediaPageState extends ConsumerState { } Widget _buildImageTile(int index) { - final String fullImagePath = path.join(imageFolderPath!.path, - widget.images[index].id + widget.images[index].fileExtension); - final file = File(fullImagePath); + final image = widget.images[index]; + final imageProvider = watchStrategyImageSource( + ref, + (id: image.id, fileExtension: image.fileExtension), + ).imageProvider; + // Keyed by asset: when a removal shifts another image into this slot, it + // must not inherit this one's frame. Gapless playback is only for the + // same asset moving from its pending bytes to its cloud URL. return Stack( + key: ValueKey(image.id), + fit: StackFit.expand, children: [ + // An Image, not a DecorationImage: on web a cloud image may paint + // through an element, which a decoration cannot draw. Container( + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - image: DecorationImage( - image: FileImage(file), // Placeholder - fit: BoxFit.cover, - ), + color: Settings.tacticalVioletTheme.secondary, ), + child: imageProvider == null + ? Center( + child: Icon( + LucideIcons.imageOff, + color: Settings.tacticalVioletTheme.secondaryForeground, + ), + ) + : Image( + key: ValueKey(image.id), + image: imageProvider, + fit: BoxFit.cover, + gaplessPlayback: true, + ), ), Positioned( top: 4, diff --git a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart index 061b5a49..6db6cf03 100644 --- a/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart +++ b/lib/widgets/dialogs/strategy/rename_strategy_dialog.dart @@ -2,17 +2,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class RenameStrategyDialog extends ConsumerStatefulWidget { final String strategyId; final String currentName; + final StrategySource source; const RenameStrategyDialog({ super.key, required this.strategyId, required this.currentName, + required this.source, }); @override @@ -52,7 +55,11 @@ class _RenameStrategyDialogState extends ConsumerState { if (strategyName.isNotEmpty) { await ref .read(strategyProvider.notifier) - .renameStrategy(widget.strategyId, strategyName); + .renameStrategy( + widget.strategyId, + strategyName, + source: widget.source, + ); if (!context.mounted) return; Navigator.of(context).pop(true); // Close the dialog with success } else { @@ -79,7 +86,11 @@ class _RenameStrategyDialogState extends ConsumerState { if (value.isNotEmpty) { await ref .read(strategyProvider.notifier) - .renameStrategy(widget.strategyId, value); + .renameStrategy( + widget.strategyId, + value, + source: widget.source, + ); if (!context.mounted) return; Navigator.of(context) .pop(true); // Close the dialog with success diff --git a/lib/widgets/dialogs/upload_image_dialog.dart b/lib/widgets/dialogs/upload_image_dialog.dart index 6edeac30..50892915 100644 --- a/lib/widgets/dialogs/upload_image_dialog.dart +++ b/lib/widgets/dialogs/upload_image_dialog.dart @@ -1,10 +1,10 @@ import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/image_provider.dart'; import 'package:icarus/services/clipboard_service.dart'; @@ -78,7 +78,6 @@ class _UploadImageDialogState extends ConsumerState { } Future _handleDrop(List files) async { - if (kIsWeb) return; if (files.isEmpty) return; XFile? imageFile; @@ -124,6 +123,8 @@ class _UploadImageDialogState extends ConsumerState { Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final bool hasSelection = _selectedBytes != null; + final policy = ref.watch(platformPolicyProvider); + final canDropFiles = policy.supports(PlatformFeature.fileDrop); Widget content = _UploadDropSquare( isDragging: _isDragging, @@ -142,8 +143,7 @@ class _UploadImageDialogState extends ConsumerState { : null, ); - // Desktop drag/drop wrapper (no-op on web). - if (!kIsWeb) { + if (canDropFiles) { content = DropTarget( onDragEntered: (_) => setState(() => _isDragging = true), onDragExited: (_) => setState(() => _isDragging = false), @@ -156,7 +156,11 @@ class _UploadImageDialogState extends ConsumerState { return ShadDialog( title: const Text('Upload image'), - description: const Text('Drop an image here or click to choose a file.'), + description: Text( + canDropFiles + ? 'Drop an image here or click to choose a file.' + : 'Click to choose a file.', + ), actions: [ ShadButton.secondary( onPressed: () => Navigator.of(context).pop(null), @@ -198,9 +202,9 @@ class _UploadImageDialogState extends ConsumerState { content, const SizedBox(height: 12), Text( - kIsWeb - ? 'Tip: Drag & drop isn’t available on web.' - : 'Tip: You can also drag & drop an image from your desktop.', + canDropFiles + ? 'Tip: You can also drag & drop an image from your desktop.' + : policy.unavailableMessage(PlatformFeature.fileDrop)!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: cs.onSurfaceVariant, ), diff --git a/lib/widgets/dialogs/web_beta_dialog.dart b/lib/widgets/dialogs/web_beta_dialog.dart new file mode 100644 index 00000000..5af2ffbc --- /dev/null +++ b/lib/widgets/dialogs/web_beta_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:url_launcher/url_launcher.dart' show launchUrl; + +/// What the web beta is, what still needs the desktop app, and where to get +/// it. The feature lists come from the platform policy so they never drift +/// from what the app actually blocks. +class WebBetaDialog extends ConsumerWidget { + const WebBetaDialog({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final policy = ref.watch(platformPolicyProvider); + return ShadDialog( + key: const ValueKey('web-beta-dialog'), + title: const Text('Icarus web beta'), + description: const Text( + 'Strategies you make here are saved to your Icarus account and open ' + 'in the desktop app too.', + ), + actions: [ + ShadButton.secondary( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ShadButton( + key: const ValueKey('web-beta-download'), + leading: const Icon(LucideIcons.download, size: 16), + onPressed: () => launchUrl(Settings.stableWindowsInstallerLink), + child: const Text('Download for Windows'), + ), + ], + child: SizedBox( + width: 420, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (policy.desktopOnly.isNotEmpty) + _FeatureList( + heading: 'Desktop-only for now', + features: policy.desktopOnly, + ), + if (policy.desktopOnly.isNotEmpty && + policy.comingToWebBeta.isNotEmpty) + const SizedBox(height: 12), + if (policy.comingToWebBeta.isNotEmpty) + _FeatureList( + heading: 'Coming to the web beta', + features: policy.comingToWebBeta, + ), + ], + ), + ), + ), + ); + } +} + +class _FeatureList extends StatelessWidget { + const _FeatureList({required this.heading, required this.features}); + + final String heading; + final Set features; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + heading, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme.mutedForeground, + ), + ), + const SizedBox(height: 4), + Text( + [for (final feature in features) feature.label].join(' · '), + style: TextStyle(fontSize: 14, color: theme.foreground), + ), + ], + ); + } +} diff --git a/lib/widgets/dot_painter.dart b/lib/widgets/dot_painter.dart index 80cf92df..53c31415 100644 --- a/lib/widgets/dot_painter.dart +++ b/lib/widgets/dot_painter.dart @@ -51,7 +51,6 @@ import 'dart:ui' show PointMode; import 'package:flutter/material.dart'; -import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; class DotGrid extends StatelessWidget { @@ -73,7 +72,6 @@ class DotPainter extends CustomPainter { Size? _cachedSize; List _cachedPoints = const []; - Size playAreaSize = CoordinateSystem.instance.playAreaSize; static const double dotSize = 3; // Size of each dot static const double dotSpacing = 9.5; @@ -124,15 +122,6 @@ class DotPainter extends CustomPainter { @override bool shouldRepaint(DotPainter oldDelegate) { - if (oldDelegate.isScreenshot != isScreenshot) { - playAreaSize = isScreenshot - ? CoordinateSystem.screenShotSize - : CoordinateSystem.instance.playAreaSize; - return true; - } - // if (oldDelegate.playAreaSize != playAreaSize) { - // return true; - // } - return false; + return oldDelegate.isScreenshot != isScreenshot; } } diff --git a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart index 94482ec6..4f609c26 100644 --- a/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart +++ b/lib/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart @@ -8,12 +8,13 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/ability_provider.dart'; -import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:icarus/widgets/dialogs/lineup_panel_dialog.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_range_fill.dart'; import 'package:icarus/widgets/draggable_widgets/adjacent_page_copy_menu.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; bool supportsAbilityVisibilityMenu(Ability? ability) { @@ -82,10 +83,11 @@ List buildLandingLineUpMenuItems( return [ ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Add lineup here'), onPressed: () { if (landing == null) return; + if (!ensureFeatureAvailable(ref, PlatformFeature.addLineups)) return; ref .read(abilityBarProvider.notifier) .updateData(AgentData.agents[landing.ability.data.type]!); @@ -96,7 +98,7 @@ List buildLandingLineUpMenuItems( }, ), ShadContextMenuItem( - leading: const Icon(LucideIcons.pencil), + leading: const Icon(LucideIcons.pencil, size: 16), child: Text(links.length > 1 ? 'Show lineups' : 'Edit media'), onPressed: () { if (context == null) return; @@ -293,15 +295,10 @@ void _updateVisualState( String? landingId, }) { if (landingId != null) { - ref.read(actionProvider.notifier).performTransaction( - groups: const [ActionGroup.lineUp], - mutation: () { - ref.read(lineUpProvider.notifier).updateLandingAbilityVisualState( - landingId: landingId, - visualState: visualState, - ); - }, - ); + ref.read(lineUpProvider.notifier).updateLandingAbilityVisualState( + landingId: landingId, + visualState: visualState, + ); return; } diff --git a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart index 8a32becc..74a1d9ea 100644 --- a/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_ability_widget.dart @@ -118,6 +118,17 @@ class _PlacedAbilityWidgetState extends ConsumerState { return Offset(rotatedX + origin.dx, rotatedY + origin.dy); } + /// Looks the ability up now, not at build: a teammate's change may have + /// moved it in the list while the handle was held. + void _commitRotation() { + final index = + PlacedWidget.getIndexByID(widget.id, ref.read(abilityProvider)); + if (index < 0) return; + ref + .read(abilityProvider.notifier) + .updateRotation(index, localRotation!, localLength ?? 0); + } + @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; @@ -339,9 +350,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { length: localLength ?? 0, ); } else { - ref - .read(abilityProvider.notifier) - .updateRotation(index, localRotation!, localLength ?? 0); + _commitRotation(); } setState(() { @@ -585,9 +594,7 @@ class _PlacedAbilityWidgetState extends ConsumerState { }); }, onPanEnd: (_) { - ref - .read(abilityProvider.notifier) - .updateRotation(index, localRotation!, localLength ?? 0); + _commitRotation(); setState(() { rotationOrigin = Offset.zero; }); diff --git a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart index 457ab29d..b6ba50f1 100644 --- a/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart +++ b/lib/widgets/draggable_widgets/ability/placed_deadlock_barrier_mesh_widget.dart @@ -217,12 +217,10 @@ class _PlacedDeadlockBarrierMeshWidgetState final shouldDuplicate = !widget.isLineUp && ref.read(duplicateDragModifierProvider); final duplicateId = shouldDuplicate - ? ref - .read(abilityProvider.notifier) - .duplicateAbilityAt( - sourceId: abilityRef.id, - position: abilityRef.position, - ) + ? ref.read(abilityProvider.notifier).duplicateAbilityAt( + sourceId: abilityRef.id, + position: abilityRef.position, + ) : null; setState(() { _isDragging = true; @@ -555,8 +553,7 @@ class _PlacedDeadlockBarrierMeshWidgetState _activeArm = arm; _hoveredArm = arm; _resizeStartArmLengthsMeters = List.from(currentArmLengths); - _armDragOffsetMeters = - currentArmLengths[arm.index] - + _armDragOffsetMeters = currentArmLengths[arm.index] - _estimateArmLengthMeters(globalPosition, arm, mapScale, abilitySize); }); _animationController.forward(); diff --git a/lib/widgets/draggable_widgets/agents/agent_widget.dart b/lib/widgets/draggable_widgets/agents/agent_widget.dart index 318310d6..5b062635 100644 --- a/lib/widgets/draggable_widgets/agents/agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/agent_widget.dart @@ -28,6 +28,8 @@ import 'package:icarus/widgets/draggable_widgets/utilities/svg_height_view_cone. import 'package:icarus/widgets/draggable_widgets/utilities/view_cone_elevation_menu.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; import 'package:icarus/widgets/mouse_watch.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Grayscale color matrix for dead agents @@ -282,9 +284,12 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && lineUpId != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Add lineup'), onPressed: () { + if (!ensureFeatureAvailable(ref, PlatformFeature.addLineups)) { + return; + } final origin = ref.read(lineUpProvider.notifier).originById(lineUpId!); if (origin == null) return; @@ -341,7 +346,7 @@ class AgentWidget extends ConsumerWidget { ), if (canInteract && viewConeAgent != null) ShadContextMenuItem( - leading: const Icon(LucideIcons.eyeOff), + leading: const Icon(LucideIcons.eyeOff, size: 16), child: const Text('Remove View Cone'), onPressed: () { ref.read(actionProvider.notifier).performTransaction( @@ -359,7 +364,7 @@ class AgentWidget extends ConsumerWidget { plainAgent != null && plainAgent.id.isNotEmpty) ShadContextMenuItem( - leading: const Icon(LucideIcons.plus), + leading: const Icon(LucideIcons.plus, size: 16), child: const Text('Create Lineup'), onPressed: () { ref.read(abilityBarProvider.notifier).updateData(agent); diff --git a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart index f2964947..87fccff3 100644 --- a/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart +++ b/lib/widgets/draggable_widgets/agents/placed_lineup_agent_widget.dart @@ -44,9 +44,8 @@ class PlacedLineupAgentWidget extends ConsumerWidget { child: draggable ? Draggable( data: agent, - dragAnchorStrategy: ref - .read(screenZoomProvider.notifier) - .zoomDragAnchorStrategy, + dragAnchorStrategy: + ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, feedback: Opacity( opacity: Settings.feedbackOpacity, child: ZoomTransform( diff --git a/lib/widgets/draggable_widgets/image/image_widget.dart b/lib/widgets/draggable_widgets/image/image_widget.dart index 592ada4d..f32a87ce 100644 --- a/lib/widgets/draggable_widgets/image/image_widget.dart +++ b/lib/widgets/draggable_widgets/image/image_widget.dart @@ -1,15 +1,12 @@ -import 'dart:io'; import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:path/path.dart' as path; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; // Full-screen overlay launcher @@ -17,8 +14,7 @@ void _showImageFullScreenOverlay({ required BuildContext context, required String heroTag, required double aspectRatio, - File? file, - String? networkLink, + required ImageProvider image, }) { Navigator.of(context).push( PageRouteBuilder( @@ -29,8 +25,7 @@ void _showImageFullScreenOverlay({ child: _ImageFullScreenOverlay( heroTag: heroTag, aspectRatio: aspectRatio, - file: file, - networkLink: networkLink, + image: image, ), ), ), @@ -41,25 +36,15 @@ class _ImageFullScreenOverlay extends StatelessWidget { const _ImageFullScreenOverlay({ required this.heroTag, required this.aspectRatio, - this.file, - this.networkLink, + required this.image, }); final String heroTag; final double aspectRatio; - final File? file; - final String? networkLink; + final ImageProvider image; @override Widget build(BuildContext context) { - final image = file != null - ? Image.file( - file!, - fit: BoxFit.contain, - ) - : (networkLink != null && networkLink!.isNotEmpty - ? Image.network(networkLink!, fit: BoxFit.contain) - : const Placeholder()); return CallbackShortcuts( bindings: { @@ -104,7 +89,7 @@ class _ImageFullScreenOverlay extends StatelessWidget { height: height, child: Hero( tag: heroTag, - child: image, + child: Image(image: image, fit: BoxFit.contain), ), ), ), @@ -140,7 +125,6 @@ class _ImageFullScreenOverlay extends StatelessWidget { class ImageWidget extends ConsumerStatefulWidget { const ImageWidget({ super.key, - required this.link, required this.aspectRatio, required this.scale, required this.fileExtension, @@ -149,7 +133,6 @@ class ImageWidget extends ConsumerStatefulWidget { this.isFeedback = false, }); final double aspectRatio; - final String? link; final double scale; final String? fileExtension; final String id; @@ -161,125 +144,146 @@ class ImageWidget extends ConsumerStatefulWidget { } class _ImageWidgetState extends ConsumerState { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - if (widget.isFeedback) return; - RenderObject? renderObject = context.findRenderObject(); - RenderBox? renderBox = renderObject as RenderBox; - // if (renderBox == null) return; - double height = renderBox.size.height; - double width = renderBox.size.width; - - Offset offset = Offset(width, height); - - ref.read(imageWidgetSizeProvider.notifier).updateSize(widget.id, offset); - }); - } - @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; - final clampedScale = ImageScalePolicy.clamp(widget.scale); - const leftChromeWidth = 12.0; // left bar (10) + spacer (2) + final metrics = PlacedImageDimensions.screenSize( + coordinateSystem: coordinateSystem, + scale: widget.scale, + aspectRatio: widget.aspectRatio, + ); final safeAspectRatio = widget.aspectRatio <= 0 ? 1.0 : widget.aspectRatio; - final totalWidth = coordinateSystem.worldWidthToScreen(clampedScale); - final cardWidth = - (totalWidth - leftChromeWidth).clamp(1.0, double.infinity); - final cardHeight = (cardWidth - 10) / safeAspectRatio + 10; - final file = File(path.join( - ref.watch(strategyProvider).storageDirectory!, - 'images', - '${widget.id}${widget.fileExtension}', - )); + final cardWidth = (metrics.width - + PlacedImageDimensions.tagWidth - + PlacedImageDimensions.tagGap) + .clamp(1.0, double.infinity); + final contentWidth = (cardWidth - (PlacedImageDimensions.imagePadding * 2)) + .clamp(1.0, double.infinity); + final source = watchStrategyImageSource( + ref, + (id: widget.id, fileExtension: widget.fileExtension), + ); + final image = source.imageProvider; - // Build the small image widget used both here and in the hero - Widget buildThumb() { - if (file.existsSync() && widget.fileExtension != null) { - return Image.file(file, fit: BoxFit.contain); - } - if (widget.link != null && widget.link!.isNotEmpty) { - return Image.network(widget.link!, fit: BoxFit.contain); - } - return const Placeholder(); - } + Widget buildThumb() => switch (source) { + // Gapless, keyed by asset: this image's pending bytes keep painting + // while its cloud URL loads, and no other image's frame carries + // over. + LocalImageFile() || RemoteImageUrl() || ImageBytes() => Image( + key: ValueKey(widget.id), + image: image!, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ImageLoading() => const _ImageStatePlaceholder( + icon: LucideIcons.loaderCircle, + label: 'Syncing image', + showSpinner: true, + ), + ImageFailed() => const _ImageStatePlaceholder( + icon: LucideIcons.imageOff, + label: 'Image unavailable', + ), + }; return GestureDetector( - onTap: () { - _showImageFullScreenOverlay( - context: context, - heroTag: 'image_${widget.id}', - file: - (file.existsSync() && widget.fileExtension != null) ? file : null, - networkLink: (file.existsSync() && widget.fileExtension != null) - ? null - : widget.link, - aspectRatio: widget.aspectRatio, - ); - }, - child: NotificationListener( - onNotification: (notification) { - if (widget.isFeedback) return true; - RenderObject? renderObject = context.findRenderObject(); - RenderBox? renderBox = renderObject as RenderBox; - double height = renderBox.size.height; - double width = renderBox.size.width; - - Offset offset = Offset(width, height); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - ref - .read(imageWidgetSizeProvider.notifier) - .updateSize(widget.id, offset); - }); - return true; - }, - child: SizeChangedLayoutNotifier( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: totalWidth, minWidth: 0), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - //Tag container - width: 10, - height: cardHeight.toDouble(), - decoration: BoxDecoration( - color: Color(widget.tagColorValue ?? 0xFFC5C5C5), - borderRadius: BorderRadius.circular(3), - ), + onTap: image == null + ? null + : () => _showImageFullScreenOverlay( + context: context, + heroTag: 'image_${widget.id}', + image: image, + aspectRatio: widget.aspectRatio, + ), + child: SizedBox( + width: metrics.width, + height: metrics.height, + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + //Tag container + width: PlacedImageDimensions.tagWidth, + decoration: BoxDecoration( + color: Color(widget.tagColorValue ?? 0xFFC5C5C5), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: PlacedImageDimensions.tagGap), + Expanded( + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(3), ), - const SizedBox(width: 2), - Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(3), + margin: EdgeInsets.zero, + color: Colors.black, + child: Padding( + padding: const EdgeInsets.all( + PlacedImageDimensions.imagePadding, ), - margin: EdgeInsets.zero, - color: Colors.black, child: SizedBox( - width: cardWidth.toDouble(), - child: Padding( - padding: const EdgeInsets.all(5), - child: AspectRatio( - aspectRatio: safeAspectRatio, - child: Container( - decoration: BoxDecoration( - color: const Color.fromARGB(255, 20, 20, 20), - borderRadius: BorderRadius.circular(3), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: Hero( - tag: 'image_${widget.id}', - child: buildThumb(), - ), - ), + width: contentWidth, + height: contentWidth / safeAspectRatio, + child: Container( + decoration: BoxDecoration( + color: const Color.fromARGB(255, 20, 20, 20), + borderRadius: BorderRadius.circular(3), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Hero( + tag: 'image_${widget.id}', + child: buildThumb(), ), ), ), ), ), + ), + ), + ], + ), + ), + ); + } +} + +class _ImageStatePlaceholder extends StatelessWidget { + const _ImageStatePlaceholder({ + required this.icon, + required this.label, + this.showSpinner = false, + }); + + final IconData icon; + final String label; + final bool showSpinner; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color.fromARGB(255, 20, 20, 20), + child: Center( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showSpinner) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon(icon, color: Colors.white70, size: 22), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), ], ), ), diff --git a/lib/widgets/draggable_widgets/image/placed_image_builder.dart b/lib/widgets/draggable_widgets/image/placed_image_builder.dart index c57745c4..eba8db3f 100644 --- a/lib/widgets/draggable_widgets/image/placed_image_builder.dart +++ b/lib/widgets/draggable_widgets/image/placed_image_builder.dart @@ -185,7 +185,6 @@ class _PlacedImageBuilderState extends State { child: IgnorePointer( child: ImageWidget( isFeedback: true, - link: widget.placedImage.link, aspectRatio: widget.placedImage.aspectRatio, scale: localScale!, fileExtension: widget.placedImage.fileExtension, @@ -219,7 +218,6 @@ class _PlacedImageBuilderState extends State { child: ImageWidget( fileExtension: widget.placedImage.fileExtension, aspectRatio: widget.placedImage.aspectRatio, - link: widget.placedImage.link, scale: localScale!, id: widget.placedImage.id, tagColorValue: widget.placedImage.tagColorValue, diff --git a/lib/widgets/draggable_widgets/placed_widget_builder.dart b/lib/widgets/draggable_widgets/placed_widget_builder.dart index ad131165..1ba28c9a 100644 --- a/lib/widgets/draggable_widgets/placed_widget_builder.dart +++ b/lib/widgets/draggable_widgets/placed_widget_builder.dart @@ -41,6 +41,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/placed_view_cone_widg import 'package:icarus/const/utilities.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; import 'package:icarus/widgets/line_up_line_painter.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; import 'package:icarus/widgets/line_up_widget.dart'; import 'package:uuid/uuid.dart'; @@ -217,7 +218,9 @@ class _PlacedWidgetBuilderState extends ConsumerState { if (ref.read(interactionStateProvider) == InteractionState.lineUpPlacing) { - ref.read(lineUpProvider.notifier).setDraftAbility(placedAbility); + ref + .read(lineUpProvider.notifier) + .setDraftAbility(placedAbility); return; } @@ -242,7 +245,9 @@ class _PlacedWidgetBuilderState extends ConsumerState { if (ref.read(interactionStateProvider) == InteractionState.lineUpPlacing) { - ref.read(lineUpProvider.notifier).setDraftAbility(placedAbility); + ref + .read(lineUpProvider.notifier) + .setDraftAbility(placedAbility); return; } @@ -405,7 +410,7 @@ bool _convertFreeUtilityToComposite({ ref.read(actionProvider.notifier).performTransaction( groups: const [ActionGroup.agent, ActionGroup.utility], mutation: () { - ref.read(utilityProvider.notifier).removeUtility(utility.id); + ref.read(utilityProvider.notifier).removeUtilityAsAction(utility.id); ref.read(agentProvider.notifier).convertPlainAgentToViewCone( id: targetAgent.id, presetType: utility.type, @@ -425,7 +430,7 @@ bool _convertFreeUtilityToComposite({ ref.read(actionProvider.notifier).performTransaction( groups: const [ActionGroup.agent, ActionGroup.utility], mutation: () { - ref.read(utilityProvider.notifier).removeUtility(utility.id); + ref.read(utilityProvider.notifier).removeUtilityAsAction(utility.id); ref.read(agentProvider.notifier).convertPlainAgentToCircle( id: targetAgent.id, diameterMeters: utility.customDiameter!, @@ -460,56 +465,62 @@ class _AbilityList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final ability in abilities) - PlacedAbilityWidget( - key: ValueKey(ability.id), - rotation: ability.rotation, - data: ability, - ability: ability, + EditorEntityLayer( + key: ValueKey('entity-${ability.id}'), id: ability.id, - length: ability.length, - onDragEnd: (details, draggedId) { - final renderBox = context.findRenderObject() as RenderBox; - final visionSpec = AbilityVisionConeSpec.forAbility(ability.data); - final coneChildOffset = - visionSpec != null && ability.visualState.showVisionCone - ? abilityVisionConeChildOffsetScreen( - coordinateSystem: coordinateSystem, - ability: ability.data.abilityData!, - mapScale: mapScale, - abilitySize: abilitySize, - ) - : Offset.zero; - final screenZoom = ref.read(screenZoomProvider); - final localOffset = renderBox.globalToLocal( - details.offset + coneChildOffset.scale(screenZoom, screenZoom), - ); - final virtualOffset = - storedAbilityPositionForRenderedScreenPosition( - ability: ability.data.abilityData!, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - abilitySize: abilitySize, - isAttack: isAttack, - ); - final safeArea = storedAbilityAnchor( - ability: ability.data.abilityData!, - mapScale: mapScale, - ); + child: PlacedAbilityWidget( + key: ValueKey(ability.id), + rotation: ability.rotation, + data: ability, + ability: ability, + id: ability.id, + length: ability.length, + onDragEnd: (details, draggedId) { + final renderBox = context.findRenderObject() as RenderBox; + final visionSpec = + AbilityVisionConeSpec.forAbility(ability.data); + final coneChildOffset = + visionSpec != null && ability.visualState.showVisionCone + ? abilityVisionConeChildOffsetScreen( + coordinateSystem: coordinateSystem, + ability: ability.data.abilityData!, + mapScale: mapScale, + abilitySize: abilitySize, + ) + : Offset.zero; + final screenZoom = ref.read(screenZoomProvider); + final localOffset = renderBox.globalToLocal( + details.offset + + coneChildOffset.scale(screenZoom, screenZoom), + ); + final virtualOffset = + storedAbilityPositionForRenderedScreenPosition( + ability: ability.data.abilityData!, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: isAttack, + ); + final safeArea = storedAbilityAnchor( + ability: ability.data.abilityData!, + mapScale: mapScale, + ); + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { + ref + .read(abilityProvider.notifier) + .removeAbilityAsAction(draggedId); + return; + } - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy), - )) { ref .read(abilityProvider.notifier) - .removeAbilityAsAction(draggedId); - return; - } - - ref - .read(abilityProvider.notifier) - .updatePosition(virtualOffset, draggedId); - }, + .updatePosition(virtualOffset, draggedId); + }, + ), ), ], ); @@ -540,147 +551,153 @@ class _AgentListState extends ConsumerState<_AgentList> { clipBehavior: Clip.none, children: [ for (final agent in agents) - switch (agent) { - PlacedAgent() => Positioned( - key: ValueKey(agent.id), - left: screenPositionForWidget( - widget: agent, - coordinateSystem: widget.coordinateSystem, - agentSize: widget.agentSize, - isAttack: isAttack, - ).dx, - top: screenPositionForWidget( - widget: agent, - coordinateSystem: widget.coordinateSystem, - agentSize: widget.agentSize, - isAttack: isAttack, - ).dy, - child: Draggable( - data: agent, - dragAnchorStrategy: zoomDragAnchorStrategy, - onDragStarted: () { - final shouldDuplicate = ref.read( - duplicateDragModifierProvider, - ); - if (!shouldDuplicate) return; - - final duplicatedId = - ref.read(agentProvider.notifier).duplicateAgentAt( - sourceId: agent.id, - position: agent.position, - ); - if (duplicatedId != null) { - _pendingDuplicateDragBySource[agent.id] = duplicatedId; - } - }, - feedback: Opacity( - opacity: Settings.feedbackOpacity, - child: ZoomTransform( - child: AgentWidget( - state: agent.state, - isAlly: agent.isAlly, - id: "", - agent: AgentData.agents[agent.type]!, - weapon: agent.weapon, + EditorEntityLayer( + key: ValueKey('entity-${agent.id}'), + id: agent.id, + child: switch (agent) { + PlacedAgent() => Positioned( + key: ValueKey(agent.id), + left: screenPositionForWidget( + widget: agent, + coordinateSystem: widget.coordinateSystem, + agentSize: widget.agentSize, + isAttack: isAttack, + ).dx, + top: screenPositionForWidget( + widget: agent, + coordinateSystem: widget.coordinateSystem, + agentSize: widget.agentSize, + isAttack: isAttack, + ).dy, + child: Draggable( + data: agent, + dragAnchorStrategy: zoomDragAnchorStrategy, + onDragStarted: () { + final shouldDuplicate = ref.read( + duplicateDragModifierProvider, + ); + if (!shouldDuplicate) return; + + final duplicatedId = + ref.read(agentProvider.notifier).duplicateAgentAt( + sourceId: agent.id, + position: agent.position, + ); + if (duplicatedId != null) { + _pendingDuplicateDragBySource[agent.id] = duplicatedId; + } + }, + feedback: Opacity( + opacity: Settings.feedbackOpacity, + child: ZoomTransform( + child: AgentWidget( + state: agent.state, + isAlly: agent.isAlly, + id: "", + agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, + ), ), ), + childWhenDragging: const SizedBox.shrink(), + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = + renderBox.globalToLocal(details.offset); + final virtualOffset = + storedAgentPositionForRenderedScreenPosition( + coordinateSystem: widget.coordinateSystem, + renderedScreenPosition: localOffset, + agentSize: widget.agentSize, + isAttack: isAttack, + ); + + final duplicateId = _pendingDuplicateDragBySource.remove( + agent.id, + ); + if (duplicateId != null) { + ref + .read(agentProvider.notifier) + .updatePosition(virtualOffset, duplicateId); + return; + } + + ref + .read(agentProvider.notifier) + .updatePosition(virtualOffset, agent.id); + }, + child: AgentWidget( + state: agent.state, + isAlly: agent.isAlly, + id: agent.id, + agent: AgentData.agents[agent.type]!, + weapon: agent.weapon, + ), ), - childWhenDragging: const SizedBox.shrink(), - onDragEnd: (details) { + ), + PlacedViewConeAgent() => PlacedViewConeAgentWidget( + key: ValueKey(agent.id), + agent: agent, + onDragEnd: (details, draggedId) { final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); + final screenZoom = ref.read(screenZoomProvider); + final agentSize = + ref.read(strategySettingsProvider).agentSize; + final compositeOffset = + viewConeAgentCompositeAgentOffsetScreen( + coordinateSystem: widget.coordinateSystem, + agentSize: agentSize, + ); + final localOffset = renderBox.globalToLocal( + details.offset + + compositeOffset.scale(screenZoom, screenZoom), + ); final virtualOffset = storedAgentPositionForRenderedScreenPosition( coordinateSystem: widget.coordinateSystem, renderedScreenPosition: localOffset, - agentSize: widget.agentSize, + agentSize: agentSize, isAttack: isAttack, ); - - final duplicateId = _pendingDuplicateDragBySource.remove( - agent.id, + ref + .read(agentProvider.notifier) + .updatePosition(virtualOffset, draggedId); + }, + ), + PlacedCircleAgent() => PlacedCircleAgentWidget( + key: ValueKey(agent.id), + agent: agent, + onDragEnd: (details, draggedId) { + final renderBox = context.findRenderObject() as RenderBox; + final screenZoom = ref.read(screenZoomProvider); + final agentSize = + ref.read(strategySettingsProvider).agentSize; + final mapScale = + Maps.mapScale[ref.read(mapProvider).currentMap] ?? 1.0; + final compositeOffset = + circleAgentCompositeAgentOffsetScreen( + coordinateSystem: widget.coordinateSystem, + agentSize: agentSize, + mapScale: mapScale, + ); + final localOffset = renderBox.globalToLocal( + details.offset + + compositeOffset.scale(screenZoom, screenZoom), + ); + final virtualOffset = + storedAgentPositionForRenderedScreenPosition( + coordinateSystem: widget.coordinateSystem, + renderedScreenPosition: localOffset, + agentSize: agentSize, + isAttack: isAttack, ); - if (duplicateId != null) { - ref - .read(agentProvider.notifier) - .updatePosition(virtualOffset, duplicateId); - return; - } - ref .read(agentProvider.notifier) - .updatePosition(virtualOffset, agent.id); + .updatePosition(virtualOffset, draggedId); }, - child: AgentWidget( - state: agent.state, - isAlly: agent.isAlly, - id: agent.id, - agent: AgentData.agents[agent.type]!, - weapon: agent.weapon, - ), ), - ), - PlacedViewConeAgent() => PlacedViewConeAgentWidget( - key: ValueKey(agent.id), - agent: agent, - onDragEnd: (details, draggedId) { - final renderBox = context.findRenderObject() as RenderBox; - final screenZoom = ref.read(screenZoomProvider); - final agentSize = - ref.read(strategySettingsProvider).agentSize; - final compositeOffset = - viewConeAgentCompositeAgentOffsetScreen( - coordinateSystem: widget.coordinateSystem, - agentSize: agentSize, - ); - final localOffset = renderBox.globalToLocal( - details.offset + - compositeOffset.scale(screenZoom, screenZoom), - ); - final virtualOffset = - storedAgentPositionForRenderedScreenPosition( - coordinateSystem: widget.coordinateSystem, - renderedScreenPosition: localOffset, - agentSize: agentSize, - isAttack: isAttack, - ); - ref - .read(agentProvider.notifier) - .updatePosition(virtualOffset, draggedId); - }, - ), - PlacedCircleAgent() => PlacedCircleAgentWidget( - key: ValueKey(agent.id), - agent: agent, - onDragEnd: (details, draggedId) { - final renderBox = context.findRenderObject() as RenderBox; - final screenZoom = ref.read(screenZoomProvider); - final agentSize = - ref.read(strategySettingsProvider).agentSize; - final mapScale = - Maps.mapScale[ref.read(mapProvider).currentMap] ?? 1.0; - final compositeOffset = circleAgentCompositeAgentOffsetScreen( - coordinateSystem: widget.coordinateSystem, - agentSize: agentSize, - mapScale: mapScale, - ); - final localOffset = renderBox.globalToLocal( - details.offset + - compositeOffset.scale(screenZoom, screenZoom), - ); - final virtualOffset = - storedAgentPositionForRenderedScreenPosition( - coordinateSystem: widget.coordinateSystem, - renderedScreenPosition: localOffset, - agentSize: agentSize, - isAttack: isAttack, - ); - ref - .read(agentProvider.notifier) - .updatePosition(virtualOffset, draggedId); - }, - ), - }, + }, + ), ], ); } @@ -701,41 +718,45 @@ class _TextList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedText in placedTexts) - PlacedTextBuilder( - key: ValueKey(placedText.id), - size: placedText.size, - placedText: placedText, - isAttack: isAttack, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final renderedSize = ref - .read(textWidgetHeightProvider.notifier) - .getOffset(placedText.id); - final attackScreenOffset = - coordinateSystem.screenPositionFromSide( - sideScreenPosition: localOffset, - reflectionOffset: renderedSize, - isAttack: isAttack, - ); - final virtualOffset = coordinateSystem.screenToCoordinate( - attackScreenOffset, - ); - final safeArea = agentSize / 2; + EditorEntityLayer( + key: ValueKey('entity-${placedText.id}'), + id: placedText.id, + child: PlacedTextBuilder( + key: ValueKey(placedText.id), + size: placedText.size, + placedText: placedText, + isAttack: isAttack, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final renderedSize = ref + .read(textWidgetHeightProvider.notifier) + .getOffset(placedText.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); + final safeArea = agentSize / 2; + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea, safeArea), + )) { + ref + .read(textProvider.notifier) + .removeTextAsAction(placedText.id); + return; + } - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea), - )) { ref .read(textProvider.notifier) - .removeTextAsAction(placedText.id); - return; - } - - ref - .read(textProvider.notifier) - .updatePosition(virtualOffset, placedText.id); - }, + .updatePosition(virtualOffset, placedText.id); + }, + ), ), ], ); @@ -760,41 +781,45 @@ class _PlacedImageList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedImage in images) - PlacedImageBuilder( - key: ValueKey(placedImage.id), - placedImage: placedImage, - isAttack: isAttack, - scale: placedImage.scale, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final renderedSize = ref - .read(imageWidgetSizeProvider.notifier) - .getSize(placedImage.id); - final attackScreenOffset = - coordinateSystem.screenPositionFromSide( - sideScreenPosition: localOffset, - reflectionOffset: renderedSize, - isAttack: isAttack, - ); - final virtualOffset = coordinateSystem.screenToCoordinate( - attackScreenOffset, - ); - final safeArea = agentSize / 2; + EditorEntityLayer( + key: ValueKey('entity-${placedImage.id}'), + id: placedImage.id, + child: PlacedImageBuilder( + key: ValueKey(placedImage.id), + placedImage: placedImage, + isAttack: isAttack, + scale: placedImage.scale, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final renderedSize = ref + .read(imageWidgetSizeProvider.notifier) + .getSize(placedImage.id); + final attackScreenOffset = + coordinateSystem.screenPositionFromSide( + sideScreenPosition: localOffset, + reflectionOffset: renderedSize, + isAttack: isAttack, + ); + final virtualOffset = coordinateSystem.screenToCoordinate( + attackScreenOffset, + ); + final safeArea = agentSize / 2; + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea, safeArea), + )) { + ref + .read(placedImageProvider.notifier) + .removeImageAsAction(placedImage.id); + return; + } - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea, safeArea), - )) { ref .read(placedImageProvider.notifier) - .removeImageAsAction(placedImage.id); - return; - } - - ref - .read(placedImageProvider.notifier) - .updatePosition(virtualOffset, placedImage.id); - }, + .updatePosition(virtualOffset, placedImage.id); + }, + ), ), ], ); @@ -822,47 +847,51 @@ class _ViewConeUtilityList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedUtility in utilities) - PlacedViewConeWidget( - key: ValueKey(placedUtility.id), - utility: placedUtility, + EditorEntityLayer( + key: ValueKey('entity-${placedUtility.id}'), id: placedUtility.id, - rotation: placedUtility.rotation, - length: placedUtility.length, - isAttack: isAttack, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final virtualOffset = - storedUtilityPositionForRenderedScreenPosition( - utility: placedUtility, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - agentSize: settings.agentSize, - abilitySize: settings.abilitySize, - isAttack: isAttack, - ); - - // if (coordinateSystem.isOutOfBounds( - // virtualOffset.translate(agentSize / 2, agentSize / 2))) { - // ref.read(utilityProvider.notifier).removeUtility(placedUtility.id); - // return; - // } + child: PlacedViewConeWidget( + key: ValueKey(placedUtility.id), + utility: placedUtility, + id: placedUtility.id, + rotation: placedUtility.rotation, + length: placedUtility.length, + isAttack: isAttack, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final virtualOffset = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); - final targetAgent = _hoveredPlainAgentTarget(ref); - if (targetAgent != null && - _convertFreeUtilityToComposite( - ref: ref, - utility: placedUtility, - targetAgent: targetAgent, - )) { - return; - } + // if (coordinateSystem.isOutOfBounds( + // virtualOffset.translate(agentSize / 2, agentSize / 2))) { + // ref.read(utilityProvider.notifier).removeUtility(placedUtility.id); + // return; + // } + + final targetAgent = _hoveredPlainAgentTarget(ref); + if (targetAgent != null && + _convertFreeUtilityToComposite( + ref: ref, + utility: placedUtility, + targetAgent: targetAgent, + )) { + return; + } - ref - .read(utilityProvider.notifier) - .updatePosition(virtualOffset, placedUtility.id); - }, + ref + .read(utilityProvider.notifier) + .updatePosition(virtualOffset, placedUtility.id); + }, + ), ), ], ); @@ -892,61 +921,65 @@ class _UtilityList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedUtility in utilities) - Positioned( - key: ValueKey(placedUtility.id), - left: screenPositionForWidget( - widget: placedUtility, - coordinateSystem: coordinateSystem, - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - isAttack: isAttack, - ).dx, - top: screenPositionForWidget( - widget: placedUtility, - coordinateSystem: coordinateSystem, - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - isAttack: isAttack, - ).dy, - child: UtilityWidgetBuilder( - rotation: placedUtility.rotation, - length: placedUtility.length, - utility: placedUtility, - id: placedUtility.id, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal(details.offset); - final virtualOffset = - storedUtilityPositionForRenderedScreenPosition( - utility: placedUtility, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - agentSize: agentSize, - abilitySize: abilitySize, - isAttack: isAttack, - ); + EditorEntityLayer( + key: ValueKey('entity-${placedUtility.id}'), + id: placedUtility.id, + child: Positioned( + key: ValueKey(placedUtility.id), + left: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, + ).dx, + top: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, + ).dy, + child: UtilityWidgetBuilder( + rotation: placedUtility.rotation, + length: placedUtility.length, + utility: placedUtility, + id: placedUtility.id, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final virtualOffset = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: agentSize, + abilitySize: abilitySize, + isAttack: isAttack, + ); - final safeArea = storedUtilityAnchor( - utility: placedUtility, - mapScale: mapScale, - ); + final safeArea = storedUtilityAnchor( + utility: placedUtility, + mapScale: mapScale, + ); + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { + ref + .read(utilityProvider.notifier) + .removeUtilityAsAction(placedUtility.id); + return; + } - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy), - )) { ref .read(utilityProvider.notifier) - .removeUtilityAsAction(placedUtility.id); - return; - } - - ref - .read(utilityProvider.notifier) - .updatePosition(virtualOffset, placedUtility.id); - }, + .updatePosition(virtualOffset, placedUtility.id); + }, + ), ), ), ], @@ -974,134 +1007,140 @@ class _CustomShapeUtilityList extends ConsumerWidget { clipBehavior: Clip.none, children: [ for (final placedUtility in customShapes) - Positioned( - key: ValueKey('custom-shape-${placedUtility.id}'), - left: screenPositionForWidget( - widget: placedUtility, - coordinateSystem: coordinateSystem, - mapScale: mapScale, - agentSize: settings.agentSize, - abilitySize: settings.abilitySize, - isAttack: isAttack, - ).dx, - top: screenPositionForWidget( - widget: placedUtility, - coordinateSystem: coordinateSystem, - mapScale: mapScale, - agentSize: settings.agentSize, - abilitySize: settings.abilitySize, - isAttack: isAttack, - ).dy, - child: placedUtility.type == UtilityType.customCircle - ? PlacedCustomCircleWidget( - utility: placedUtility, - id: placedUtility.id, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal( - details.offset, - ); - final virtualOffset = - storedUtilityPositionForRenderedScreenPosition( - utility: placedUtility, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - agentSize: settings.agentSize, - abilitySize: settings.abilitySize, - isAttack: isAttack, - ); - - final diameterMeters = placedUtility.customDiameter; - if (diameterMeters == null) { - ref - .read(utilityProvider.notifier) - .removeUtility(placedUtility.id); - return; - } - - final safeArea = UtilityData - .utilityWidgets[placedUtility.type]! - .getAnchorPoint( - mapScale: mapScale, - diameterMeters: diameterMeters, - ); - - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy), - )) { - ref - .read(utilityProvider.notifier) - .removeUtilityAsAction(placedUtility.id); - return; - } - - final targetAgent = _hoveredPlainAgentTarget(ref); - if (targetAgent != null && - _convertFreeUtilityToComposite( - ref: ref, - utility: placedUtility, - targetAgent: targetAgent, - )) { - return; - } - - ref - .read(utilityProvider.notifier) - .updatePosition(virtualOffset, placedUtility.id); - }, - ) - : PlacedCustomRectangleWidget( - utility: placedUtility, - id: placedUtility.id, - isAttack: isAttack, - onDragEnd: (details) { - final renderBox = context.findRenderObject() as RenderBox; - final localOffset = renderBox.globalToLocal( - details.offset, - ); - final virtualOffset = - storedUtilityPositionForRenderedScreenPosition( - utility: placedUtility, - coordinateSystem: coordinateSystem, - renderedScreenPosition: localOffset, - mapScale: mapScale, - agentSize: settings.agentSize, - abilitySize: settings.abilitySize, - isAttack: isAttack, - ); + EditorEntityLayer( + key: ValueKey('entity-${placedUtility.id}'), + id: placedUtility.id, + child: Positioned( + key: ValueKey('custom-shape-${placedUtility.id}'), + left: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ).dx, + top: screenPositionForWidget( + widget: placedUtility, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ).dy, + child: placedUtility.type == UtilityType.customCircle + ? PlacedCustomCircleWidget( + utility: placedUtility, + id: placedUtility.id, + onDragEnd: (details) { + final renderBox = + context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal( + details.offset, + ); + final virtualOffset = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); + + final diameterMeters = placedUtility.customDiameter; + if (diameterMeters == null) { + ref + .read(utilityProvider.notifier) + .removeUtility(placedUtility.id); + return; + } + + final safeArea = UtilityData + .utilityWidgets[placedUtility.type]! + .getAnchorPoint( + mapScale: mapScale, + diameterMeters: diameterMeters, + ); + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { + ref + .read(utilityProvider.notifier) + .removeUtilityAsAction(placedUtility.id); + return; + } + + final targetAgent = _hoveredPlainAgentTarget(ref); + if (targetAgent != null && + _convertFreeUtilityToComposite( + ref: ref, + utility: placedUtility, + targetAgent: targetAgent, + )) { + return; + } - final widthMeters = placedUtility.customWidth; - final lengthMeters = placedUtility.customLength; - if (widthMeters == null || lengthMeters == null) { ref .read(utilityProvider.notifier) - .removeUtility(placedUtility.id); - return; - } + .updatePosition(virtualOffset, placedUtility.id); + }, + ) + : PlacedCustomRectangleWidget( + utility: placedUtility, + id: placedUtility.id, + isAttack: isAttack, + onDragEnd: (details) { + final renderBox = + context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal( + details.offset, + ); + final virtualOffset = + storedUtilityPositionForRenderedScreenPosition( + utility: placedUtility, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + agentSize: settings.agentSize, + abilitySize: settings.abilitySize, + isAttack: isAttack, + ); + + final widthMeters = placedUtility.customWidth; + final lengthMeters = placedUtility.customLength; + if (widthMeters == null || lengthMeters == null) { + ref + .read(utilityProvider.notifier) + .removeUtility(placedUtility.id); + return; + } + + final width = widthMeters * + AgentData.inGameMetersDiameter * + mapScale; + final length = lengthMeters * + AgentData.inGameMetersDiameter * + mapScale; + final safeArea = Offset(length / 2, width / 2); + + if (coordinateSystem.isOutOfBounds( + virtualOffset.translate(safeArea.dx, safeArea.dy), + )) { + ref + .read(utilityProvider.notifier) + .removeUtilityAsAction(placedUtility.id); + return; + } - final width = widthMeters * - AgentData.inGameMetersDiameter * - mapScale; - final length = lengthMeters * - AgentData.inGameMetersDiameter * - mapScale; - final safeArea = Offset(length / 2, width / 2); - - if (coordinateSystem.isOutOfBounds( - virtualOffset.translate(safeArea.dx, safeArea.dy), - )) { ref .read(utilityProvider.notifier) - .removeUtilityAsAction(placedUtility.id); - return; - } - - ref - .read(utilityProvider.notifier) - .updatePosition(virtualOffset, placedUtility.id); - }, - ), + .updatePosition(virtualOffset, placedUtility.id); + }, + ), + ), ), ], ); @@ -1131,11 +1170,41 @@ class _LineUpAgents extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { ref.watch(canvasResizeProvider); final origins = ref.watch(lineUpProvider.select((state) => state.origins)); + final coordinateSystem = CoordinateSystem.instance; + final agentSize = ref.watch(strategySettingsProvider).agentSize; + final isAttack = ref.watch(mapProvider).isAttack; return Stack( clipBehavior: Clip.none, children: [ - for (final origin in origins) LineUpOriginAgentWidget(origin: origin), + for (final origin in origins) + EditorEntityLayer( + key: ValueKey('entity-${origin.id}'), + id: origin.id, + child: LineUpOriginAgentWidget( + origin: origin, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final position = storedAgentPositionForRenderedScreenPosition( + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + agentSize: agentSize, + isAttack: isAttack, + ); + // Dropped off the map: the origin stays where it was. + if (coordinateSystem + .isOutOfBounds(position + storedAgentAnchor)) { + return; + } + // Records a move of this origin only, so undo never rolls + // back lineups that arrived since. + ref + .read(lineUpProvider.notifier) + .updateOriginAgentPosition(origin.id, position); + }, + ), + ), ], ); } @@ -1149,12 +1218,44 @@ class _LineUpAbilities extends ConsumerWidget { ref.watch(canvasResizeProvider); final landings = ref.watch(lineUpProvider.select((state) => state.landings)); + final coordinateSystem = CoordinateSystem.instance; + final mapState = ref.watch(mapProvider); + final mapScale = Maps.mapScale[mapState.currentMap] ?? 1.0; + final abilitySize = ref.watch(strategySettingsProvider).abilitySize; return Stack( clipBehavior: Clip.none, children: [ for (final landing in landings) - LineUpLandingAbilityWidget(landing: landing), + EditorEntityLayer( + key: ValueKey('entity-${landing.id}'), + id: landing.id, + child: LineUpLandingAbilityWidget( + landing: landing, + onDragEnd: (details) { + final renderBox = context.findRenderObject() as RenderBox; + final localOffset = renderBox.globalToLocal(details.offset); + final abilityData = landing.ability.data.abilityData!; + final position = storedAbilityPositionForRenderedScreenPosition( + ability: abilityData, + coordinateSystem: coordinateSystem, + renderedScreenPosition: localOffset, + mapScale: mapScale, + abilitySize: abilitySize, + isAttack: mapState.isAttack, + ); + // Dropped off the map: the landing stays where it was. + final anchor = storedAbilityAnchor( + ability: abilityData, + mapScale: mapScale, + ); + if (coordinateSystem.isOutOfBounds(position + anchor)) return; + ref + .read(lineUpProvider.notifier) + .updateLandingPosition(landing.id, position); + }, + ), + ), ], ); } diff --git a/lib/widgets/draggable_widgets/text/text_widget.dart b/lib/widgets/draggable_widgets/text/text_widget.dart index b610be17..6913b82d 100644 --- a/lib/widgets/draggable_widgets/text/text_widget.dart +++ b/lib/widgets/draggable_widgets/text/text_widget.dart @@ -79,12 +79,14 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { final _tapGroup = Object(); final _portalController = OverlayPortalController(); bool _editing = false; + bool _syncingController = false; @override void initState() { super.initState(); _draftNotifier = ref.read(textDraftProvider.notifier); - _controller = MarkupTextEditingController(text: _effectiveText()); + _controller = MarkupTextEditingController(text: _effectiveText()) + ..addListener(_onControllerChanged); _focusNode = FocusNode()..addListener(_onFocusChange); _draftSubscription = ref.listenManual>( textDraftProvider, @@ -114,7 +116,9 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { _focusNode ..removeListener(_onFocusChange) ..dispose(); - _controller.dispose(); + _controller + ..removeListener(_onControllerChanged) + ..dispose(); super.dispose(); } @@ -130,6 +134,15 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { _portalController.hide(); } + /// Every text change reaches the draft, whether it came from typing, the + /// format bar, or accessibility, so sync and save never miss an edit. + void _onControllerChanged() { + if (_syncingController) return; + final nextText = _controller.text; + if (nextText == _effectiveText()) return; + _draftNotifier.setDraft(widget.id, nextText); + } + void _syncControllerWithExternalState() { if (!_controller.value.isComposingRangeValid) { _controller.clearComposing(); @@ -140,12 +153,17 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { final baseOffset = selection.baseOffset.clamp(0, nextText.length).toInt(); final extentOffset = selection.extentOffset.clamp(0, nextText.length).toInt(); - _controller.value = TextEditingValue( - text: nextText, - selection: selection.isValid - ? TextSelection(baseOffset: baseOffset, extentOffset: extentOffset) - : TextSelection.collapsed(offset: nextText.length), - ); + _syncingController = true; + try { + _controller.value = TextEditingValue( + text: nextText, + selection: selection.isValid + ? TextSelection(baseOffset: baseOffset, extentOffset: extentOffset) + : TextSelection.collapsed(offset: nextText.length), + ); + } finally { + _syncingController = false; + } } TextStyle _bodyStyle(BuildContext context) { @@ -158,7 +176,6 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { void _applyValue(TextEditingValue value) { _controller.value = value; - _draftNotifier.setDraft(widget.id, value.text); } void _enterEditing() { @@ -196,19 +213,22 @@ class _EditableTextWidgetState extends ConsumerState<_EditableTextWidget> { 'Write here...', style: bodyStyle.copyWith(color: Colors.grey), ), - TextField( - focusNode: _focusNode, - controller: _controller, - inputFormatters: [ListContinuationFormatter()], - groupId: _tapGroup, - style: bodyStyle, - decoration: null, - maxLines: null, - minLines: null, - expands: false, - onChanged: (value) => - _draftNotifier.setDraft(widget.id, value), - onTapOutside: (_) => _focusNode.unfocus(), + MergeSemantics( + child: Semantics( + label: 'Placed text', + child: TextField( + focusNode: _focusNode, + controller: _controller, + inputFormatters: [ListContinuationFormatter()], + groupId: _tapGroup, + style: bodyStyle, + decoration: null, + maxLines: null, + minLines: null, + expands: false, + onTapOutside: (_) => _focusNode.unfocus(), + ), + ), ), ], ), diff --git a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart index 2c834d35..a604a799 100644 --- a/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart +++ b/lib/widgets/draggable_widgets/utilities/placed_view_cone_widget.dart @@ -71,6 +71,9 @@ class _PlacedViewConeWidgetState extends ConsumerState { return Offset(rotatedX + origin.dx, rotatedY + origin.dy); } + int _currentIndex() => + PlacedWidget.getIndexByID(widget.id, ref.read(utilityProvider)); + @override Widget build(BuildContext context) { final coordinateSystem = CoordinateSystem.instance; @@ -147,7 +150,9 @@ class _PlacedViewConeWidgetState extends ConsumerState { buttonLeft: anchorPoint.dx - 7.5, onPanStart: (details) { // Save rotation history for undo/redo - ref.read(utilityProvider.notifier).updateRotationHistory(index); + ref + .read(utilityProvider.notifier) + .updateRotationHistory(_currentIndex()); // Get the global position of the anchor point final box = context.findRenderObject() as RenderBox; @@ -191,9 +196,14 @@ class _PlacedViewConeWidgetState extends ConsumerState { }); }, onPanEnd: (details) { - ref - .read(utilityProvider.notifier) - .updateRotation(index, localRotation!, localLength ?? 50); + // Looked up now, not at build: a teammate's change may have moved + // the cone in the list while the handle was held. + final current = _currentIndex(); + if (current >= 0) { + ref + .read(utilityProvider.notifier) + .updateRotation(current, localRotation!, localLength ?? 50); + } setState(() { rotationOrigin = Offset.zero; diff --git a/lib/widgets/editor_operation_scope.dart b/lib/widgets/editor_operation_scope.dart new file mode 100644 index 00000000..5f48c7c9 --- /dev/null +++ b/lib/widgets/editor_operation_scope.dart @@ -0,0 +1,123 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; + +/// Includes sidebar drags and controls in overlays as well as the canvas. +class EditorOperationScope extends ConsumerStatefulWidget { + const EditorOperationScope({required this.child, super.key}); + + final Widget child; + + @override + ConsumerState createState() => + _EditorOperationScopeState(); +} + +class _EditorOperationScopeState extends ConsumerState { + /// Pointer id to the device that pressed it. Trackpad pan/zoom gestures map + /// to null: the cursor may hover while two fingers are still down. + final _pointers = {}; + late EditorPointersNotifier _notifier; + + @override + void initState() { + super.initState(); + _notifier = ref.read(editorPointersProvider.notifier); + } + + void _hold(int pointer, int? device) { + _pointers[pointer] = device; + _notifier.down(pointer); + } + + void _release(PointerEvent event) { + _pointers.remove(event.pointer); + _notifier.release(event.pointer); + } + + /// A hovering device has no buttons down. If its up event was lost (for + /// example, released outside the window), stop holding back remote updates. + void _releaseDevice(PointerEvent event) { + final stale = [ + for (final entry in _pointers.entries) + if (entry.value == event.device) entry.key, + ]; + for (final pointer in stale) { + _pointers.remove(pointer); + _notifier.release(pointer); + } + } + + @override + void dispose() { + for (final pointer in _pointers.keys) { + _notifier.release(pointer); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) => Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) => _hold(event.pointer, event.device), + onPointerUp: _release, + onPointerCancel: _release, + // Trackpad pinches and two-finger drags reach the same onPan* handlers + // as a pressed pointer, so they hold remote updates back too. + onPointerPanZoomStart: (event) => _hold(event.pointer, null), + onPointerPanZoomEnd: _release, + onPointerHover: _releaseDevice, + child: widget.child, + ); +} + +/// The strategy canvas. A press here that lands on no item (drawing, panning) +/// holds nothing back: remote changes keep arriving while it lasts. +class EditorCanvasRegion extends ConsumerWidget { + const EditorCanvasRegion({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + void mark(PointerEvent event) => + ref.read(editorPointersProvider.notifier).markCanvas(event.pointer); + // Translucent: any press inside the canvas counts, even where the canvas + // widgets under it only listen. + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: mark, + onPointerPanZoomStart: mark, + child: child, + ); + } +} + +/// One canvas item, as a child of the canvas's item [Stack]. While a press on +/// it (a drag, a rotate or resize handle) lasts, remote changes to this item +/// wait; the rest of the page updates. +/// +/// The item sits in its own full-size layer so its positioned widget lays out +/// exactly as before, and the listener only fires when the item itself is hit. +class EditorEntityLayer extends ConsumerWidget { + const EditorEntityLayer({required this.id, required this.child, super.key}); + + final String id; + + /// A [Positioned] (or similar) item widget. + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + void hold(PointerEvent event) => + ref.read(editorPointersProvider.notifier).holdEntity(event.pointer, id); + return Positioned.fill( + child: Listener( + behavior: HitTestBehavior.deferToChild, + onPointerDown: hold, + onPointerPanZoomStart: hold, + child: Stack(clipBehavior: Clip.none, children: [child]), + ), + ); + } +} diff --git a/lib/widgets/editor_toolbar.dart b/lib/widgets/editor_toolbar.dart index d45e2252..eca8856c 100644 --- a/lib/widgets/editor_toolbar.dart +++ b/lib/widgets/editor_toolbar.dart @@ -1,29 +1,31 @@ -import 'dart:io'; - import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:hive_ce/hive.dart'; import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/drawing_provider.dart'; -import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/screenshot/capture_geometry.dart'; -import 'package:icarus/screenshot/offscreen_capture.dart'; -import 'package:icarus/screenshot/persistent_offscreen_renderer.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:icarus/screenshot/page_screenshot.dart'; import 'package:icarus/services/app_error_reporter.dart'; -import 'package:icarus/screenshot/screenshot_view.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/dialogs/export_video_dialog.dart'; import 'package:icarus/widgets/settings_tab.dart'; import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Geometry shared by every control in the editor's floating toolbar, so the -/// save button matches its neighbours exactly. +/// save button (which swaps between local and cloud variants) matches its +/// neighbours exactly. class EditorToolbarButtonStyle { const EditorToolbarButtonStyle({this.size = 32, this.iconSize = 18}); @@ -46,9 +48,25 @@ class EditorToolbar extends ConsumerStatefulWidget { class _EditorToolbarState extends ConsumerState { bool _isCapturingScreenshot = false; + /// From the click until the PNG is saved or the save dialog closes: a + /// second click meanwhile would capture again and open a second dialog. + bool _screenshotInProgress = false; + @override Widget build(BuildContext context) { const style = kEditorToolbarButtonStyle; + final source = ref.watch(strategyProvider.select((value) => value.source)); + final isCloud = source == StrategySource.cloud; + final strategyId = + ref.watch(strategyProvider.select((value) => value.strategyId)); + // Read through a share link, with no account: nothing here saves, and + // signing in is what adds the strategy to the reader's library. + final viewingThroughLink = isCloud && + ref.watch(shareLinkViewProvider)?.strategyPublicId == strategyId; + // Local saving exists only where the local library does; the web beta + // never offers it, even if an editor somehow opens with no cloud strategy. + final allowsLocalSave = + ref.watch(platformPolicyProvider).allowsLocalLibrary; // The strategy view owns the spacing around this card, so it aligns // with the map card above it. @@ -64,7 +82,20 @@ class _EditorToolbarState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.min, children: [ - const AutoSaveButton(style: style), + if (viewingThroughLink) + EditorToolbarButton( + style: style, + tooltip: 'View only · Sign in to add it to your library', + onPressed: () => showDialog( + context: context, + builder: (_) => const AuthDialog(), + ), + icon: const Icon(LucideIcons.logIn200), + ) + else if (isCloud) + const CloudSyncButton(style: style) + else if (allowsLocalSave) + const AutoSaveButton(style: style), EditorToolbarButton( style: style, tooltip: 'Export .ica', @@ -113,29 +144,26 @@ class _EditorToolbarState extends ConsumerState { ); } - void _showDesktopOnlyToast() { - Settings.showToast( - message: 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - Future _exportStrategy() async { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; + if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) return; + + final strategy = ref.read(strategyProvider); + final strategyId = strategy.strategyId; + if (strategyId == null || strategy.source == null) { + throw StateError('No strategy is open for export.'); } - await ref - .read(strategyProvider.notifier) - .exportFile(ref.read(strategyProvider).id); + final exporter = StrategyImportExportService(ref); + switch (strategy.source!) { + case StrategySource.cloud: + await runCloudStrategyExport(ref, strategyId); + case StrategySource.local: + await exporter.exportFile(strategyId); + } } void _exportVideo() { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; - } + if (!ensureFeatureAvailable(ref, PlatformFeature.videoExport)) return; showShadDialog( context: context, builder: (context) => const ExportVideoDialog(), @@ -143,99 +171,44 @@ class _EditorToolbarState extends ConsumerState { } Future _captureScreenshot() async { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; - } - if (_isCapturingScreenshot) return; + if (!ensureFeatureAvailable(ref, PlatformFeature.screenshot)) return; + if (_screenshotInProgress) return; + final strategy = ref.read(strategyProvider); + if (strategy.strategyId == null) return; + _screenshotInProgress = true; setState(() => _isCapturingScreenshot = true); - ProviderContainer? screenshotContainer; - CaptureGeometryLease? captureGeometry; try { - final String id = ref.read(strategyProvider).id; - - await ref.read(strategyProvider.notifier).forceSaveNow(id); - if (!mounted) return; - - final newStrat = Hive.box( - HiveBoxNames.strategiesBox, - ).values.where((StrategyData strategy) => strategy.id == id).firstOrNull; - if (newStrat == null) return; - - final currentPageID = ref.read(strategyProvider.notifier).activePageID; - final mapState = ref.read(mapProvider); - if (currentPageID == null) return; - - final activePage = newStrat.pages.firstWhere( - (p) => p.id == currentPageID, - orElse: () => newStrat.pages.first, - ); - final captureContainer = ProviderContainer(); - screenshotContainer = captureContainer; - - final screenshotView = ScreenshotView( - isAttack: activePage.isAttack, - mapValue: newStrat.mapData, - showSpawnBarrier: mapState.showSpawnBarrier, - showRegionNames: mapState.showRegionNames, - showUltOrbs: mapState.showUltOrbs, - agents: activePage.agentData, - abilities: activePage.abilityData, - text: activePage.textData, - images: activePage.imageData, - drawings: activePage.drawingData, - utilities: activePage.utilityData, - strategySettings: activePage.settings, - strategyState: ref.read(strategyProvider), - pageName: activePage.name, - lineUpGraph: activePage.lineUpGraph, - themeProfileId: newStrat.themeProfileId, - themeOverridePalette: newStrat.themeOverridePalette, - ); - // The sightline models load asynchronously; the capture waits for the - // page's geometry before the first frame is taken. - captureGeometry = await prepareCaptureGeometry( - captureContainer, - newStrat.mapData, - [activePage], - ); - late Uint8List image; + late final Uint8List image; try { - image = await withScreenshotCoordinates(() async { - screenshotView.hydrateProviders(captureContainer); - final renderer = PersistentOffscreenRenderer( - targetSize: CoordinateSystem.screenShotSize, - waitForFrameData: captureGeometry?.waitForFrame, - wrapWidget: (child) => - wrapForOffscreenCapture(child, container: captureContainer)); - try { - await renderer.prepare(screenshotView, - settleDuration: const Duration(milliseconds: 800)); - return await renderer.capture(screenshotView); - } finally { - await renderer.dispose(); - } - }); + image = await captureEditorPage(ref); } finally { if (mounted) { ref.read(screenshotProvider.notifier).setIsScreenShot(false); ref .read(drawingProvider.notifier) .rebuildAllPaths(CoordinateSystem.instance); + setState(() => _isCapturingScreenshot = false); } } if (!mounted) return; - setState(() => _isCapturingScreenshot = false); - String? outputFile = await FilePicker.platform.saveFile( + // Desktop asks where to save and writes the bytes there; the browser + // downloads them. + await FilePicker.platform.saveFile( type: FileType.custom, dialogTitle: 'Please select an output file:', - fileName: "${ref.read(strategyProvider).stratName ?? "new image"}.png", + fileName: + '${sanitizeStrategyFileName(strategy.strategyName ?? 'new image')}.png', allowedExtensions: ['png'], + bytes: image, + ); + } on CaptureImagesUnavailable catch (error, stackTrace) { + AppErrorReporter.reportWarning( + error.userMessage, + error: error, + stackTrace: stackTrace, + source: 'EditorToolbar.screenshot', + promptUser: true, ); - if (outputFile != null) { - final file = File(outputFile); - await file.writeAsBytes(image); - } } catch (error, stackTrace) { AppErrorReporter.reportError( 'Could not export the screenshot. Please try again.', @@ -244,19 +217,16 @@ class _EditorToolbarState extends ConsumerState { source: 'EditorToolbar.screenshot', ); } finally { - captureGeometry?.close(); - screenshotContainer?.dispose(); - if (mounted && _isCapturingScreenshot) { - setState(() => _isCapturingScreenshot = false); - } + _screenshotInProgress = false; } } } -/// One control in the editor toolbar. Glyphs are the 200 stroke weight: the -/// default 2px Lucide stroke reads heavy in white at 18px, and muted grey -/// vanishes against the card, so the weight carries the quietness instead. [icon] is any 18px glyph, so buttons can swap -/// in a spinner without changing size. +/// One control in the editor toolbar. Glyphs are the 200 stroke weight and +/// rest in [Settings.toolbarGlyph]: the default 2px Lucide stroke reads heavy +/// at 18px, and full white on top of it shouts, so the weight and a step of +/// grey share the quietness. Hover brings the glyph up to foreground. [icon] +/// is any 18px glyph, so buttons can swap in a spinner without changing size. class EditorToolbarButton extends StatelessWidget { // The ghost icon button's own corner radius (the theme default). static const double _buttonRadius = 6; @@ -285,7 +255,7 @@ class EditorToolbarButton extends StatelessWidget { /// [tooltip] still labels the button for screen readers. final bool showTooltip; - /// Overrides the resting color, e.g. destructive for a problem. + /// Overrides the resting color, e.g. destructive for a sync problem. final Color? foregroundColor; final String? semanticsLabel; diff --git a/lib/widgets/folder_card.dart b/lib/widgets/folder_card.dart index 0946aadb..66cbe8b5 100644 --- a/lib/widgets/folder_card.dart +++ b/lib/widgets/folder_card.dart @@ -5,15 +5,22 @@ import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:icarus/widgets/overflow_tooltip_text.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -52,8 +59,23 @@ class FolderCardViewData { mapPeeks = _collectMapPeeks(strategies), agentTypes = _collectAgents(strategies); + /// A folder summarised elsewhere, e.g. by the cloud: counts, map peeks, and + /// agents arrive ready-made instead of being derived from strategies here. + FolderCardViewData.summary({ + required this.folder, + required this.folderCount, + this.strategyCount, + List maps = const [], + this.agentTypes = const [], + }) : mapPeeks = [ + for (final map in maps.take(2)) + 'assets/maps/thumbnails/${Maps.mapNames[map]}_thumbnail.webp', + ]; + final Folder folder; - final int strategyCount; + + /// Null when the folder's strategies are not known here. + final int? strategyCount; final int folderCount; /// Distinct map thumbnail assets, most used first, capped at 2. @@ -118,10 +140,19 @@ class FolderCard extends ConsumerStatefulWidget { const FolderCard({ super.key, required this.data, + this.store = LibraryWorkspace.local, + this.cloudRole, this.isDemo = false, }); final FolderCardViewData data; + + /// Which library the folder belongs to. My Library lists both stores, so + /// the card cannot infer this from the active workspace. + final LibraryWorkspace store; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; final bool isDemo; @override @@ -143,6 +174,10 @@ class _FolderCardState extends ConsumerState final DragTiltController _dragTiltController = DragTiltController(); Folder get _folder => widget.data.folder; + bool get _isCloud => widget.store == LibraryWorkspace.cloud; + + /// Shared cloud folders can be opened but only their owner reshapes them. + bool get _canManage => !_isCloud || widget.cloudRole == 'owner'; @override void initState() { @@ -212,8 +247,13 @@ class _FolderCardState extends ConsumerState String? currentParentId = _folder.parentID; while (currentParentId != null) { if (currentParentId == folderId) return true; - final parentFolder = - ref.read(folderProvider.notifier).findFolderByID(currentParentId); + final folders = ref.read(folderProvider.notifier); + final parentFolder = _isCloud + ? folders.findCloudFolderByID( + currentParentId, + ref.read(cloudAllFoldersProvider).valueOrNull ?? const [], + ) + : folders.findLocalFolderByID(currentParentId); currentParentId = parentFolder?.parentID; } return false; @@ -236,6 +276,8 @@ class _FolderCardState extends ConsumerState return DragTarget(onWillAcceptWithDetails: (details) { final item = details.data; if (widget.isDemo) return false; + if (!_canManage) return false; + if (item.store != widget.store) return false; if (item is FolderItem) { return item.folder.id != id && !_isParentFolder(item.folder.id); } @@ -286,13 +328,19 @@ class _FolderCardState extends ConsumerState } if (item is StrategyItem) { - ref - .read(strategyProvider.notifier) - .moveToFolder(strategyID: item.strategy.id, parentID: _folder.id); + await ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, + parentID: _folder.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, + ); } else if (item is FolderItem) { - ref - .read(folderProvider.notifier) - .moveToFolder(folderID: item.folder.id, parentID: _folder.id); + await ref.read(folderProvider.notifier).moveToFolder( + folderID: item.folder.id, + parentID: _folder.id, + workspace: widget.store, + ); } }, builder: (context, candidateData, rejectedData) { final isPinnedDropTarget = candidateData.any( @@ -317,7 +365,7 @@ class _FolderCardState extends ConsumerState dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(_folder), + data: FolderItem(_folder, store: widget.store), child: MouseRegion( onEnter: (_) { _isHovered = true; @@ -334,7 +382,10 @@ class _FolderCardState extends ConsumerState child: GestureDetector( onTap: () { if (widget.isDemo) return; - ref.read(folderProvider.notifier).updateID(_folder.id); + ref.read(folderProvider.notifier).openFolder( + folderId: _folder.id, + store: widget.store, + ); }, child: AnimatedBuilder( animation: _open, @@ -571,10 +622,11 @@ class _FolderCardState extends ConsumerState Expanded( child: agents.isEmpty ? Text( - widget.data.strategyCount == 0 - ? 'Empty' - : '${widget.data.strategyCount} ' - 'strateg${widget.data.strategyCount == 1 ? 'y' : 'ies'}', + switch (widget.data.strategyCount) { + null => '', + 0 => 'Empty', + final count => '$count strateg${count == 1 ? 'y' : 'ies'}', + }, style: TextStyle( color: Colors.white.withValues(alpha: 0.55), fontSize: 11, @@ -583,10 +635,11 @@ class _FolderCardState extends ConsumerState ) : _AgentComposition(agents: agents), ), - if (widget.data.strategyCount > 0 || widget.data.folderCount > 0) ...[ + if ((widget.data.strategyCount ?? 0) > 0 || + widget.data.folderCount > 0) ...[ const SizedBox(width: 6), _CountBadge( - strategyCount: widget.data.strategyCount, + strategyCount: widget.data.strategyCount ?? 0, folderCount: widget.data.folderCount, ), ], @@ -651,47 +704,70 @@ class _FolderCardState extends ConsumerState ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showDialog( + context: context, + builder: (context) { + return FolderEditDialog( + folder: _folder, + store: widget.store, + ); + }, + ); + }, child: const Text('Edit'), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog(folder: _folder); - }, - ); - }, ), + if (_isCloud && widget.cloudRole == 'owner') + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () async { + _closeMenus(); + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'folder', + targetPublicId: _folder.id, + title: _folder.name, + ), + ); + }, + ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), child: const Text('Export'), onPressed: () async { _closeMenus(); - await ref.read(strategyProvider.notifier).exportFolder(_folder.id); + if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) { + return; + } + await StrategyImportExportService(ref).exportFolder(_folder.id); }, ), ShadContextMenuItem( leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), + enabled: _canManage, + onPressed: !_canManage + ? null + : () async { + _closeMenus(); + if (widget.isDemo) return; + await showShadDialog( + context: context, + builder: (_) => DeleteFolderAlertDialog( + folder: _folder, + workspace: widget.store, + ), + ); + }, child: Text('Delete', style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), - onPressed: () async { - _closeMenus(); - ConfirmAlertDialog.show( - context: context, - title: "Are you sure you want to delete '${_folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder(_folder.id); - } - }); - }, ), ]; } diff --git a/lib/widgets/folder_content.dart b/lib/widgets/folder_content.dart index 75ea7615..94d381b8 100644 --- a/lib/widgets/folder_content.dart +++ b/lib/widgets/folder_content.dart @@ -1,20 +1,35 @@ +import 'dart:math' as math; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/client_upgrade_button.dart'; import 'package:icarus/widgets/custom_search_field.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; import 'package:icarus/widgets/library_breadcrumb.dart'; -import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/library_entries.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/hover_dot_grid.dart'; +import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @visibleForTesting @@ -47,7 +62,6 @@ DateTime folderLastUpdated({ required Iterable allStrategies, }) { var latest = folder.dateCreated; - for (final strategy in strategiesInFolderTree( folder: folder, allFolders: allFolders, @@ -57,7 +71,6 @@ DateTime folderLastUpdated({ latest = strategy.lastEdited; } } - return latest; } @@ -98,7 +111,6 @@ int compareFoldersForSort({ ), ), }; - if (result != 0) return result; return a.id.compareTo(b.id); } @@ -118,31 +130,516 @@ Set _folderAndDescendantIds(Folder root, Iterable allFolders) { if (!ids.add(current.id)) continue; pending.addAll(foldersByParent[current.id] ?? const []); } - return ids; } class FolderContent extends ConsumerWidget { - FolderContent({super.key, this.folder, required this.onCreateStrategy}); - - final Folder? folder; // null for root + const FolderContent({ + super.key, + this.folder, + required this.onCreateStrategy, + }); - /// Opens the create-strategy dialog; offered from the empty state. + /// The open folder, or null at a tab's root. + final Folder? folder; final VoidCallback onCreateStrategy; - final strategiesListenable = + + static final strategiesListenable = Provider>>((ref) { return Hive.box(HiveBoxNames.strategiesBox).listenable(); }); - final foldersListenable = Provider>>((ref) { + static final foldersListenable = + Provider>>((ref) { return Hive.box(HiveBoxNames.foldersBox).listenable(); }); @override Widget build(BuildContext context, WidgetRef ref) { - // Move all your existing grid logic here from FolderView - // Filter by folder?.id instead of folder.id + final tab = ref.watch(libraryTabProvider); + switch (tab) { + case LibraryTab.community: + return _buildCommunityPlaceholder(context, ref); + case LibraryTab.shared: + return _crossFade(_buildCloudBody(context, ref)); + case LibraryTab.library: + if (ref.watch(librarySignInRequiredProvider)) { + return _crossFade(_buildSignInState(context, ref)); + } + if (folder == null) { + return _crossFade(_buildLibraryRoot(context, ref)); + } + final store = ref.watch(libraryWorkspaceProvider); + if (store == LibraryWorkspace.cloud) { + return _crossFade(_buildCloudBody(context, ref)); + } + return _buildLocalFolder(context, ref); + } + } + + /// Skeleton -> content (and error transitions) cross-fade instead of + /// hard-snapping. + Widget _crossFade(Widget child) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: child, + ); + } + + /// Reads the local library and hands the visible rows to [builder]. Used + /// both inside a local folder and for the local half of the My Library root. + Widget _withLocalStore( + BuildContext context, + WidgetRef ref, { + required Widget Function( + List folders, + List strategies, + ) builder, + }) { + // Hidden, not deleted: the on-device library stays in storage for the + // day this platform offers it again. + if (!ref.watch(platformPolicyProvider).allowsLocalLibrary) { + return builder(const [], const []); + } + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); final strategiesBoxListenable = ref.watch(strategiesListenable); + final foldersBoxListenable = ref.watch(foldersListenable); + return ValueListenableBuilder>( + valueListenable: strategiesBoxListenable, + builder: (context, strategyBox, _) { + return ValueListenableBuilder>( + valueListenable: foldersBoxListenable, + builder: (context, folderBox, _) { + final allFolders = folderBox.values.toList(); + final allStrategies = strategyBox.values.toList(); + final existingFolderIds = allFolders.map((item) => item.id).toSet(); + final folders = [ + for (final item in allFolders) + if (folderBelongsToVisibleParent( + folder: item, + currentFolderId: folder?.id, + )) + LibraryFolderRow( + folder: item, + store: LibraryWorkspace.local, + lastUpdated: folderLastUpdated( + folder: item, + allFolders: allFolders, + allStrategies: allStrategies, + ), + card: FolderCardViewData( + folder: item, + strategies: strategiesInFolderTree( + folder: item, + allFolders: allFolders, + allStrategies: allStrategies, + ), + folderCount: allFolders + .where((folder) => folder.parentID == item.id) + .length, + ), + ), + ]; + final strategies = [ + for (final item in allStrategies) + if (strategyBelongsToVisibleFolder( + strategy: item, + currentFolderId: folder?.id, + existingFolderIds: existingFolderIds, + )) + LibraryStrategyRow.local( + item, + showDeviceBadge: cloudAvailable, + ), + ]; + return builder(folders, strategies); + }, + ); + }, + ); + } + + Widget _buildLocalFolder(BuildContext context, WidgetRef ref) { + return _withLocalStore( + context, + ref, + builder: (folders, strategies) => _buildScaffold( + context, + ref, + folders: _filterFolders(ref, folders), + strategies: _filterStrategies(ref, strategies), + acceptsIcaDrops: _acceptsIcaDrops(ref), + emptyStateTitle: 'No strategies in this folder', + emptyStateSubtitle: + 'Create a new strategy or drop strategies, folders, or .zip archives', + emptyStateAction: ShadButton( + key: const ValueKey('folder-empty-create-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(LucideIcons.plus), + child: const Text('Create Strategy'), + ), + ), + ); + } + + /// My Library's root: everything on this computer and everything in the + /// cloud, side by side. Inside a folder the view narrows to that folder's + /// store. + Widget _buildLibraryRoot(BuildContext context, WidgetRef ref) { + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final foldersAsync = + cloudAvailable ? ref.watch(cloudFoldersProvider) : null; + final strategiesAsync = + cloudAvailable ? ref.watch(cloudStrategiesProvider) : null; + // Only the very first fetch shows the skeleton; dependency changes keep + // the previous value. + final isInitialLoading = foldersAsync != null && + strategiesAsync != null && + ((foldersAsync.isLoading && !foldersAsync.hasValue) || + (strategiesAsync.isLoading && !strategiesAsync.hasValue)); + if (isInitialLoading) { + return const _LibraryLoadingSkeleton(key: ValueKey('cloud-loading')); + } + final cloudFailed = (foldersAsync?.hasError ?? false) || + (strategiesAsync?.hasError ?? false); + // With no local half to fall back on, a failed cloud is the whole page. + if (cloudFailed && !ref.watch(platformPolicyProvider).allowsLocalLibrary) { + return KeyedSubtree( + key: const ValueKey('cloud-error'), + child: _buildCloudErrorState(context, ref, offerLibrary: false), + ); + } + final cloudFolders = [ + for (final entry + in foldersAsync?.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), + ]; + final cloudStrategies = [ + for (final entry + in strategiesAsync?.valueOrNull ?? const []) + LibraryStrategyRow.cloud(entry), + ]; + + return KeyedSubtree( + key: const ValueKey('library-root'), + child: _withLocalStore( + context, + ref, + builder: (localFolders, localStrategies) => _buildScaffold( + context, + ref, + folders: _filterFolders( + ref, + mergeLibraryFolders(local: localFolders, cloud: cloudFolders), + ), + strategies: _filterStrategies( + ref, + mergeLibraryStrategies( + local: localStrategies, + cloud: cloudStrategies, + ), + ), + acceptsIcaDrops: _acceptsIcaDrops(ref), + banner: cloudFailed + ? _CloudErrorBanner(onRetry: () => _retryCloud(ref)) + : null, + emptyStateKey: const ValueKey('library-empty-state'), + emptyStateIcon: LucideIcons.folder, + emptyStateTitle: 'Your library is empty', + emptyStateSubtitle: cloudAvailable + ? 'Create your first strategy to keep it available across your ' + 'Icarus clients.' + : 'Create a new strategy or drop strategies, folders, or .zip ' + 'archives here.', + emptyStateAction: ShadButton( + key: const ValueKey('library-empty-create-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(LucideIcons.plus), + child: const Text('Create Strategy'), + ), + ), + ), + ); + } + + /// Dropped .ica files and backups import into the local library. + bool _acceptsIcaDrops(WidgetRef ref) { + final policy = ref.watch(platformPolicyProvider); + return policy.allowsLocalLibrary && + policy.supports(PlatformFeature.importFiles); + } + + LibraryFolderRow _cloudFolderRow(WidgetRef ref, CloudFolderEntry entry) { + final tree = ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []; + return LibraryFolderRow( + folder: entry.folder, + store: LibraryWorkspace.cloud, + lastUpdated: entry.folder.dateCreated, + cloudRole: entry.role, + card: FolderCardViewData.summary( + folder: entry.folder, + folderCount: tree + .where((item) => item.folder.parentID == entry.folder.id) + .length, + strategyCount: entry.strategyCount, + maps: entry.mapPeeks, + agentTypes: entry.agentTypes, + ), + ); + } + + void _retryCloud(WidgetRef ref) { + ref.invalidate(cloudFolderTreeProvider); + ref.invalidate(cloudStrategiesProvider); + } + + /// A cloud folder, or the Shared tab. + Widget _buildCloudBody(BuildContext context, WidgetRef ref) { + final cloudSection = ref.watch(cloudLibrarySectionProvider); + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + if (!cloudAvailable) { + return KeyedSubtree( + key: const ValueKey('cloud-unavailable'), + child: _buildCloudUnavailableState(context, ref), + ); + } + final foldersAsync = ref.watch(cloudFoldersProvider); + final strategiesAsync = ref.watch(cloudStrategiesProvider); + if (foldersAsync.hasError || strategiesAsync.hasError) { + return KeyedSubtree( + key: const ValueKey('cloud-error'), + child: _buildCloudErrorState(context, ref), + ); + } + final isInitialLoading = + (foldersAsync.isLoading && !foldersAsync.hasValue) || + (strategiesAsync.isLoading && !strategiesAsync.hasValue); + if (isInitialLoading) { + return const _LibraryLoadingSkeleton( + key: ValueKey('cloud-loading'), + ); + } + final folders = [ + for (final entry + in foldersAsync.valueOrNull ?? const []) + _cloudFolderRow(ref, entry), + ]; + final strategies = [ + for (final entry + in strategiesAsync.valueOrNull ?? const []) + LibraryStrategyRow.cloud(entry), + ]; + final isSharedWithMe = cloudSection == CloudLibrarySection.sharedWithMe; + return KeyedSubtree( + key: const ValueKey('cloud-content'), + child: _buildScaffold( + context, + ref, + folders: _filterFolders(ref, folders), + strategies: _filterStrategies(ref, strategies), + acceptsIcaDrops: false, + emptyStateKey: isSharedWithMe && folder == null + ? const ValueKey('shared-empty-state') + : null, + emptyStateIcon: + isSharedWithMe && folder == null ? LucideIcons.users : null, + emptyStateTitle: isSharedWithMe && folder == null + ? 'Nothing shared with you yet' + : 'No strategies in this folder', + emptyStateSubtitle: isSharedWithMe && folder == null + ? 'Add a share link or code from a teammate to keep it here.' + : isSharedWithMe + ? 'Strategies shared into this folder will show up here.' + : 'Create a new strategy to fill it.', + emptyStateAction: isSharedWithMe + ? (folder == null + ? ShadButton( + key: const ValueKey('shared-empty-add-item'), + onPressed: () => showAddSharedItemDialog(context), + leading: const Icon(LucideIcons.link), + child: const Text('Add by Link or Code'), + ) + : null) + : ShadButton( + key: const ValueKey('cloud-empty-create-strategy'), + onPressed: onCreateStrategy, + leading: const Icon(LucideIcons.plus), + child: const Text('Create Strategy'), + ), + ), + ); + } + + List _filterFolders( + WidgetRef ref, + List folders, + ) { + final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final filter = ref.watch(strategyFilterProvider); + final filtered = search.isEmpty + ? folders + : folders + .where((row) => row.folder.name.toLowerCase().contains(search)) + .toList(); + final sorted = sortLibraryFolders(filtered, filter); + final pinned = ref.watch(pinnedItemsProvider); + return search.isEmpty && pinned.isNotEmpty + ? sortPinnedItemsFirst(sorted, pinned, (row) => row.id) + : sorted; + } + + List _filterStrategies( + WidgetRef ref, + List strategies, + ) { + final search = ref.watch(strategySearchQueryProvider).trim().toLowerCase(); + final filter = ref.watch(strategyFilterProvider); + final filtered = search.isEmpty + ? strategies + : strategies + .where((row) => row.name.toLowerCase().contains(search)) + .toList(); + final sorted = sortLibraryStrategies(filtered, filter); + final pinned = ref.watch(pinnedItemsProvider); + return search.isEmpty && pinned.isNotEmpty + ? sortPinnedItemsFirst(sorted, pinned, (row) => row.id) + : sorted; + } + + Widget _buildScaffold( + BuildContext context, + WidgetRef ref, { + required List folders, + required List strategies, + required bool acceptsIcaDrops, + Widget? banner, + Key? emptyStateKey, + IconData? emptyStateIcon, + required String emptyStateTitle, + required String emptyStateSubtitle, + Widget? emptyStateAction, + }) { + final hasStrategies = strategies.isNotEmpty; + // A search that filtered everything out is not an empty library: say so, + // and offer nothing to create. + final searching = ref.watch(strategySearchQueryProvider).trim().isNotEmpty; + final icon = searching ? null : emptyStateIcon; + final title = searching ? 'No matches' : emptyStateTitle; + final subtitle = searching ? 'Try a different search' : emptyStateSubtitle; + final action = searching ? null : emptyStateAction; + final Widget emptyState = Center( + key: emptyStateKey, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 38, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + const SizedBox(height: 16), + ], + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ), + if (action != null) ...[ + const SizedBox(height: 18), + action, + ], + ], + ), + ), + ); + final Widget content = LayoutBuilder( + builder: (context, constraints) { + const double minTileWidth = 250; + const double spacing = strategyTileGridSpacing; + const double padding = 32; + final crossAxisCount = math.max( + 1, + ((constraints.maxWidth - padding + spacing) / + (minTileWidth + spacing)) + .floor(), + ); + + return CustomScrollView( + slivers: [ + // Folder cards, on the same columns as the strategies below. + if (folders.isNotEmpty) + SliverPadding( + // Each card carries half the gutter as drop hit area, so + // the row's padding shrinks by that much to keep x=16. + padding: const EdgeInsets.fromLTRB( + 16 - folderCardGutterOutset, + 16, + 16 - folderCardGutterOutset, + 8, + ), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: folderCardHeight, + mainAxisSpacing: 14, + ), + delegate: SliverChildListDelegate.fixed( + [ + for (final row in folders) + FolderCard( + key: ValueKey(row.id), + data: row.card, + store: row.store, + cloudRole: row.cloudRole, + ), + ], + ), + ), + ), + if (hasStrategies) + SliverPadding( + padding: const EdgeInsets.all(16 - strategyTileGutterOutset), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: strategyTileGridMainAxisExtent, + ), + delegate: SliverChildListDelegate.fixed( + [ + for (final row in strategies) + if (row.local case final local?) + StrategyTile.local( + key: ValueKey(row.id), + strategyData: local, + showDeviceBadge: row.showDeviceBadge, + ) + else + _cloudTile(row.cloud!), + ], + ), + ), + ), + ], + ); + }, + ); + final wrappedContent = acceptsIcaDrops + ? IcaDropTarget(child: DropInsertionIndicatorScope(child: content)) + : DropInsertionIndicatorScope(child: content); + final currentFolder = folder; return Listener( behavior: HitTestBehavior.translucent, @@ -158,223 +655,28 @@ class FolderContent extends ConsumerWidget { Positioned.fill( child: Column( children: [ - if (folder != null) + if (currentFolder != null) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: LibraryBreadcrumb(folder: folder!), + child: LibraryBreadcrumb(folder: currentFolder), ), + if (banner != null) banner, Expanded( - child: ValueListenableBuilder>( - valueListenable: strategiesBoxListenable, - builder: (context, strategyBox, _) { - final foldersBoxListenable = ref.watch(foldersListenable); - return ValueListenableBuilder>( - valueListenable: foldersBoxListenable, - builder: (context, folderBox, _) { - final allFolders = folderBox.values.toList(); - final existingFolderIds = - allFolders.map((folder) => folder.id).toSet(); - var folders = allFolders.toList(); - - final allStrategies = strategyBox.values.toList(); - var strategies = allStrategies.toList(); - - final search = ref - .watch(strategySearchQueryProvider) - .trim() - .toLowerCase(); - // Filter strategies and folders by the current folder - strategies.removeWhere( - (strategy) => !strategyBelongsToVisibleFolder( - strategy: strategy, - currentFolderId: folder?.id, - existingFolderIds: existingFolderIds, - ), - ); - folders.removeWhere( - (listFolder) => !folderBelongsToVisibleParent( - folder: listFolder, - currentFolderId: folder?.id, - ), - ); - - if (search.isNotEmpty) { - strategies.retainWhere( - (strategy) => - strategy.name.toLowerCase().contains(search), - ); - folders.retainWhere( - (listFolder) => listFolder.name - .toLowerCase() - .contains(search), - ); - } - final filter = ref.watch(strategyFilterProvider); - - // Pick the comparator once based on sortBy - Comparator sortByComparator = - switch (filter.sortBy) { - SortBy.alphabetical => (a, b) => a.name - .toLowerCase() - .compareTo(b.name.toLowerCase()), - SortBy.dateCreated => (a, b) => - a.createdAt.compareTo(b.createdAt), - SortBy.dateUpdated => (a, b) => - a.lastEdited.compareTo(b.lastEdited), - }; - - final direction = - filter.sortOrder == SortOrder.ascending ? 1 : -1; - - strategies.sort( - (a, b) => direction * sortByComparator(a, b), - ); - folders.sort( - (a, b) => - direction * - compareFoldersForSort( - a: a, - b: b, - sortBy: filter.sortBy, - allFolders: allFolders, - allStrategies: allStrategies, - ), - ); - - final pinned = ref.watch(pinnedItemsProvider); - if (search.isEmpty && pinned.isNotEmpty) { - folders = sortPinnedItemsFirst( - folders, - pinned, - (folder) => folder.id, - ); - strategies = sortPinnedItemsFirst( - strategies, - pinned, - (strategy) => strategy.id, - ); - } - - // Check if both folders and strategies are empty - if (folders.isEmpty && strategies.isEmpty) { - return IcaDropTarget( - child: _EmptyState( - searching: search.isNotEmpty, - onCreateStrategy: onCreateStrategy, - ), - ); - } - - return IcaDropTarget( - child: DropInsertionIndicatorScope( - child: LayoutBuilder( - builder: (context, constraints) { - // Calculate how many columns can fit with minimum width - const double minTileWidth = - 250; // Your minimum width - const double padding = 32; // 16 * 2 - - int crossAxisCount = ((constraints.maxWidth - - padding + - strategyTileGridSpacing) / - (minTileWidth + - strategyTileGridSpacing)) - .floor(); - crossAxisCount = crossAxisCount - .clamp(1, double.infinity) - .toInt(); - - return CustomScrollView( - slivers: [ - // Folder cards, on the same columns as - // the strategies below. - if (folders.isNotEmpty) - SliverPadding( - // Horizontal padding is reduced by the - // gutter baked into each FolderCard so - // the cards still align at x=16. - padding: const EdgeInsets.fromLTRB( - 16 - folderCardGutterOutset, - 16, - 16 - folderCardGutterOutset, - 8), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisExtent: folderCardHeight, - // Zero spacing: each card carries - // half the gutter as drop hit - // area on both sides. - crossAxisSpacing: 0, - mainAxisSpacing: 14, - ), - delegate: - SliverChildBuilderDelegate( - (context, index) { - final f = folders[index]; - return FolderCard( - key: ValueKey(f.id), - data: FolderCardViewData( - folder: f, - strategies: - strategiesInFolderTree( - folder: f, - allFolders: allFolders, - allStrategies: - allStrategies, - ), - folderCount: allFolders - .where((folder) => - folder.parentID == - f.id) - .length, - ), - ); - }, - childCount: folders.length, - ), - ), - ), - - // Strategies grid - if (strategies.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.all( - 16 - strategyTileGutterOutset, - ), - sliver: SliverGrid( - gridDelegate: - SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisExtent: - strategyTileGridMainAxisExtent, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: - SliverChildBuilderDelegate( - (context, index) { - return StrategyTile( - key: ValueKey( - strategies[index].id), - strategyData: - strategies[index], - ); - }, - childCount: strategies.length, - ), - ), - ), - ], - ); - }, - ), - ), - ); - }, - ); - }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: (folders.isEmpty && !hasStrategies) + ? KeyedSubtree( + key: const ValueKey('library-empty'), + child: acceptsIcaDrops + ? IcaDropTarget(child: emptyState) + : emptyState, + ) + : KeyedSubtree( + key: const ValueKey('library-content'), + child: wrappedContent, + ), ), ), ], @@ -384,45 +686,410 @@ class FolderContent extends ConsumerWidget { ), ); } + + Widget _cloudTile(CloudStrategyEntry entry) { + final caps = StrategyCapabilities.fromCloudRole(entry.role); + return StrategyTile.cloud( + key: ValueKey(entry.strategy.id), + cloudStrategy: entry, + canRename: caps.canRenameStrategy, + canDuplicate: caps.canDuplicateStrategy, + canDelete: caps.canDeleteStrategy, + canMove: caps.canMoveStrategy, + ); + } + + Widget _buildCloudUnavailableState(BuildContext context, WidgetRef ref) { + // A live session that cannot reach the cloud retries; only a signed-out + // user is asked to log in. + final signedIn = ref.watch(authProvider).isAuthenticated; + return _LibraryMessageState( + icon: LucideIcons.cloudOff, + iconColor: Settings.tacticalVioletTheme.mutedForeground, + title: 'Cloud unavailable', + subtitle: signedIn + ? 'Retry to reach your online strategies, or go back to your ' + 'library to keep working.' + : 'Sign in again to reach your online strategies, or go back ' + 'to your library to keep working.', + actions: [ + if (signedIn) + ShadButton( + leading: const Icon(LucideIcons.refreshCw, size: 14), + onPressed: () => ref + .read(authProvider.notifier) + .reinitializeConvexAuth(source: 'cloud_unavailable_retry'), + child: const Text('Retry'), + ) + else + ShadButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + }, + child: const Text('Log In'), + ), + ShadButton.secondary( + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), + ), + ], + ); + } + + /// [offerLibrary] is false when this state already fills My Library, so + /// "Back to My Library" would go nowhere. + Widget _buildCloudErrorState( + BuildContext context, + WidgetRef ref, { + bool offerLibrary = true, + }) { + return _LibraryMessageState( + icon: LucideIcons.cloudOff, + iconColor: Settings.tacticalVioletTheme.destructive, + title: "Couldn't load your cloud library", + subtitle: 'Check your connection and try again.', + actions: [ + ShadButton( + leading: const Icon(LucideIcons.refreshCw, size: 14), + onPressed: () => _retryCloud(ref), + child: const Text('Retry'), + ), + if (offerLibrary) + ShadButton.secondary( + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), + ), + ], + ); + } + + /// My Library on a platform that needs the cloud, before the cloud is + /// reachable. Restoring a session holds the skeleton so the login prompt + /// never flashes at a returning user; a session that cannot reach the + /// cloud gets a retry, never a second login over the one it has. + Widget _buildSignInState(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final restoring = auth.isLoading || + auth.convexAuthStatus == ConvexAuthStatus.configuring; + if (restoring) { + return const _LibraryLoadingSkeleton(key: ValueKey('cloud-loading')); + } + if (auth.isAuthenticated) { + // The server refuses this build: a retry is refused like the first + // try, and only a reload or an update helps. + if (ref.watch(clientUpgradeRequiredProvider)) { + return KeyedSubtree( + key: const ValueKey('library-cloud-unreachable'), + child: ClientUpgradeNotice( + builder: (context, message, action) => _LibraryMessageState( + icon: LucideIcons.cloudOff, + iconColor: Settings.tacticalVioletTheme.destructive, + title: 'Your cloud library needs a newer Icarus', + subtitle: message, + actions: [if (action != null) action], + ), + ), + ); + } + return KeyedSubtree( + key: const ValueKey('library-cloud-unreachable'), + child: _LibraryMessageState( + icon: LucideIcons.cloudOff, + iconColor: Settings.tacticalVioletTheme.destructive, + title: "Couldn't reach your cloud library", + subtitle: auth.errorMessage ?? 'Check your connection and try again.', + actions: [ + ShadButton( + key: const ValueKey('library-cloud-retry'), + leading: const Icon(LucideIcons.refreshCw, size: 14), + onPressed: () => ref + .read(authProvider.notifier) + .reinitializeConvexAuth(source: 'library_retry'), + child: const Text('Retry'), + ), + ], + ), + ); + } + return KeyedSubtree( + key: const ValueKey('library-sign-in-state'), + child: _LibraryMessageState( + icon: LucideIcons.logIn, + iconColor: Settings.tacticalVioletTheme.mutedForeground, + title: 'Log in to use the web beta', + subtitle: 'Strategies you make here are saved to your Icarus ' + 'account.', + actions: [ + ShadButton( + key: const ValueKey('library-sign-in-action'), + onPressed: () { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + }, + child: const Text('Log In'), + ), + ], + ), + ); + } + + Widget _buildCommunityPlaceholder(BuildContext context, WidgetRef ref) { + return _LibraryMessageState( + icon: LucideIcons.globe, + iconColor: Settings.tacticalVioletTheme.primary, + title: 'Community strats are coming soon', + subtitle: + 'This space is reserved for public lineups, team executes, and discoverable strategy packs.', + actions: [ + ShadButton.secondary( + onPressed: ref.read(libraryNavigationProvider).showLibrary, + child: const Text('Back to My Library'), + ), + ], + ); + } } -/// What the grid shows when nothing is in it: an invitation to create the -/// first strategy, or a plain "no matches" when a search filtered it out. -class _EmptyState extends StatelessWidget { - const _EmptyState({ - required this.searching, - required this.onCreateStrategy, - }); +/// Shown above the My Library root when the cloud half failed to load. The +/// local half stays usable underneath. +class _CloudErrorBanner extends StatelessWidget { + const _CloudErrorBanner({required this.onRetry}); - final bool searching; - final VoidCallback onCreateStrategy; + final VoidCallback onRetry; @override Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - final title = searching ? 'No matches' : 'No strategies yet'; - final hint = searching - ? 'Try a different search' - : 'Create one, or drop strategies, folders, or .zip archives here'; - - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(title, style: theme.textTheme.p), - const SizedBox(height: 4), - Text(hint, style: theme.textTheme.muted), - if (!searching) ...[ - const SizedBox(height: 16), - ShadButton( - key: const ValueKey('library-empty-new-strategy'), - onPressed: onCreateStrategy, - leading: const Icon(LucideIcons.plus, size: 16), - child: const Text('New Strategy'), + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: Container( + key: const ValueKey('cloud-error-banner'), + padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Settings.tacticalVioletTheme.border), + ), + child: Row( + children: [ + Icon( + LucideIcons.cloudOff, + size: 16, + color: Settings.tacticalVioletTheme.destructive, + ), + const SizedBox(width: 10), + const Expanded( + child: Text( + "Couldn't load your cloud library. Showing what's on this " + 'computer.', + ), + ), + ShadButton.ghost( + height: 28, + leading: const Icon(LucideIcons.refreshCw, size: 14), + onPressed: onRetry, + child: const Text('Retry'), ), ], - ], + ), ), ); } } + +/// Shared full-pane message layout (dot-grid backdrop, icon, title, subtitle, +/// action row) used by the community placeholder and cloud +/// unavailable/error states so they carry the same visual weight. +class _LibraryMessageState extends StatelessWidget { + const _LibraryMessageState({ + required this.icon, + required this.iconColor, + required this.title, + required this.subtitle, + required this.actions, + }); + + final IconData icon; + final Color iconColor; + final String title; + final String subtitle; + final List actions; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4.0), + child: HoverDotGrid(), + ), + ), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 38, color: iconColor), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + actions[i], + ], + ], + ), + ], + ), + ), + ), + ], + ); + } +} + +/// Pulsing placeholder shown while the cloud library streams its first +/// snapshot — previously the grid rendered the empty state during fetch. +class _LibraryLoadingSkeleton extends StatefulWidget { + const _LibraryLoadingSkeleton({super.key}); + + @override + State<_LibraryLoadingSkeleton> createState() => + _LibraryLoadingSkeletonState(); +} + +class _LibraryLoadingSkeletonState extends State<_LibraryLoadingSkeleton> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (MediaQuery.of(context).disableAnimations) { + _controller.stop(); + _controller.value = 1; + } else if (!_controller.isAnimating) { + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final placeholderColor = + Settings.tacticalVioletTheme.muted.withValues(alpha: 0.35); + return Stack( + children: [ + const Positioned.fill( + child: Padding( + padding: EdgeInsets.all(4.0), + child: HoverDotGrid(), + ), + ), + Positioned.fill( + child: FadeTransition( + opacity: Tween(begin: 0.45, end: 0.9).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ), + child: LayoutBuilder( + builder: (context, constraints) { + const double minTileWidth = 250; + const double spacing = 20; + const double padding = 32; + final crossAxisCount = math.max( + 1, + ((constraints.maxWidth - padding + spacing) / + (minTileWidth + spacing)) + .floor(), + ); + final tileCount = crossAxisCount * 2; + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + for (var i = 0; i < 3; i++) + Container( + width: 128, + height: 44, + decoration: BoxDecoration( + color: placeholderColor, + borderRadius: BorderRadius.circular(22), + ), + ), + ], + ), + const SizedBox(height: 20), + Expanded( + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisExtent: 250, + crossAxisSpacing: 20, + mainAxisSpacing: 20, + ), + itemCount: tileCount, + itemBuilder: (context, index) => DecoratedBox( + decoration: BoxDecoration( + color: placeholderColor, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/folder_edit_dialog.dart b/lib/widgets/folder_edit_dialog.dart index 8f79739a..8fd1c560 100644 --- a/lib/widgets/folder_edit_dialog.dart +++ b/lib/widgets/folder_edit_dialog.dart @@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/folder_icons.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/cloud_library_action.dart'; import 'package:icarus/widgets/better_color_picker.dart'; import 'package:icarus/widgets/color_picker_button.dart'; import 'package:icarus/widgets/custom_segmented_tabs.dart'; @@ -24,8 +27,12 @@ class FolderEditDialog extends ConsumerStatefulWidget { const FolderEditDialog({ super.key, this.folder, + this.store, }); final Folder? folder; + + /// The store to write to. Defaults to the active workspace. + final LibraryWorkspace? store; @override ConsumerState createState() => _FolderEditDialogState(); @@ -38,46 +45,32 @@ class _FolderEditDialogState extends ConsumerState { FolderColor _selectedColor = FolderColor.red; Color? _customColor; _FolderIconFilter _iconFilter = _FolderIconFilter.all; - @override - void dispose() { - _folderNameController.dispose(); - super.dispose(); - } - - @override - void initState() { - super.initState(); - // Listen to text changes and rebuild - _selectedColor = widget.folder?.color ?? FolderColor.generic; - if (widget.folder != null) { - _folderNameController.text = widget.folder!.name; - _selectedIconId = widget.folder!.iconId; - _customColor = widget.folder!.customColor; - } - _folderNameController.addListener(() { - setState(() {}); - }); - } - bool _isSubmitting = false; + String? _failureMessage; /// Saves the folder and closes the dialog; Enter in the name field and the /// Done button both land here, and a second submit while one is in flight - /// is ignored. + /// is ignored. A failed cloud write keeps the dialog open with the reason. Future _submit() async { if (_isSubmitting) return; - setState(() => _isSubmitting = true); + setState(() { + _isSubmitting = true; + _failureMessage = null; + }); + final name = _folderNameController.text.isEmpty - ? "New Folder" + ? 'New Folder' : _folderNameController.text; + CloudLibraryActionResult? result; try { if (widget.folder != null) { - ref.read(folderProvider.notifier).editFolder( + result = await ref.read(folderProvider.notifier).editFolder( folder: widget.folder!, newName: name, newIconId: _selectedIconId, newColor: _selectedColor, newCustomColor: _customColor, + workspace: widget.store, ); } else { await ref.read(folderProvider.notifier).createFolder( @@ -85,12 +78,54 @@ class _FolderEditDialogState extends ConsumerState { iconId: _selectedIconId, color: _selectedColor, customColor: _customColor, + workspace: widget.store, ); + result = CloudLibraryActionResult.succeeded; + } + } catch (error, stackTrace) { + AppErrorReporter.reportError( + 'Failed to save a library folder.', + source: 'folder_dialog:save', + error: error, + stackTrace: stackTrace, + promptUser: false, + ); + if (mounted) { + setState(() { + _failureMessage = "Couldn't save this folder. Try again."; + }); } } finally { if (mounted) setState(() => _isSubmitting = false); } - if (mounted) Navigator.of(context).pop(); + if (!mounted) return; + if (result?.didSucceed == true) { + Navigator.of(context).pop(); + } else if (result != null) { + setState(() => _failureMessage = + result!.userMessage ?? "Couldn't save this folder. Try again."); + } + } + + @override + void dispose() { + _folderNameController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + // Listen to text changes and rebuild + _selectedColor = widget.folder?.color ?? FolderColor.generic; + if (widget.folder != null) { + _folderNameController.text = widget.folder!.name; + _selectedIconId = widget.folder!.iconId; + _customColor = widget.folder!.customColor; + } + _folderNameController.addListener(() { + setState(() {}); + }); } @override @@ -113,17 +148,28 @@ class _FolderEditDialogState extends ConsumerState { Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: ShadButton( - leading: const Icon(LucideIcons.check), + key: const ValueKey('folder-edit-submit'), + leading: _isSubmitting ? null : const Icon(LucideIcons.check), onPressed: _isSubmitting ? null : _submit, - child: const Text("Done"), + child: Text(_isSubmitting ? 'Saving...' : 'Done'), ), - ) + ), ], child: SizedBox( width: 358, child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_failureMessage != null) ...[ + Text( + _failureMessage!, + key: const ValueKey('folder-edit-failure'), + style: TextStyle( + color: Settings.tacticalVioletTheme.destructive, + ), + ), + const SizedBox(height: 10), + ], Container( height: 220, width: 358, @@ -195,6 +241,7 @@ class _FolderEditDialogState extends ConsumerState { onTap: () { setState(() { _selectedColor = color; + _customColor = null; }); // ref.read(penProvider.notifier).setColor(index); }, diff --git a/lib/widgets/folder_navigator.dart b/lib/widgets/folder_navigator.dart index e8a59a14..4b49003f 100644 --- a/lib/widgets/folder_navigator.dart +++ b/lib/widgets/folder_navigator.dart @@ -1,27 +1,33 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' + show TargetPlatform, defaultTargetPlatform, kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/update_checker.dart'; import 'package:icarus/main.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/desktop_update_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/update_status_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/strategy_view.dart'; import 'package:icarus/widgets/desktop_update_dialog.dart'; -import 'package:icarus/widgets/demo_dialog.dart'; -import 'package:icarus/widgets/library_title_strip.dart'; import 'package:icarus/widgets/dialogs/strategy/create_strategy_dialog.dart'; import 'package:icarus/widgets/dialogs/web_view_dialog.dart'; import 'package:icarus/widgets/folder_content.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; +import 'package:icarus/widgets/library_title_strip.dart'; import 'package:icarus/widgets/folder_edit_dialog.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class FolderNavigator extends ConsumerStatefulWidget { @@ -38,6 +44,9 @@ class _FolderNavigatorState extends ConsumerState { final ShadContextMenuController _backgroundMenuController = ShadContextMenuController(); + bool get _isWindowsDesktop => + !kIsWeb && defaultTargetPlatform == TargetPlatform.windows; + @override void dispose() { _backgroundMenuController.dispose(); @@ -48,21 +57,23 @@ class _FolderNavigatorState extends ConsumerState { void initState() { super.initState(); - // Show the demo warning only once after the first frame on web. + // Warn about a missing WebView only once, after the first frame. WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _followCloudAvailability(ref.read(isCloudWorkspaceAvailableProvider)); if (!_warnedOnce) { _warnedOnce = true; _warnWebView(); - - _warnDemo(); } }); } void _warnWebView() async { if (kIsWeb) return; - if (!Platform.isWindows) return; + if (!_isWindowsDesktop) return; + await warmUpWebViewEnvironment(); + if (!mounted) return; if (isWebViewInitialized) return; await showShadDialog( context: context, @@ -72,30 +83,10 @@ class _FolderNavigatorState extends ConsumerState { ); } - void _warnDemo() async { - if (!kIsWeb) return; - await showShadDialog( - context: context, - builder: (context) { - return const DemoDialog(); - }, - ); - } - - void _showDesktopOnlyToast() { - Settings.showToast( - message: 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); - } - Future handleImportIca() async { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; - } + if (!ensureFeatureAvailable(ref, PlatformFeature.importFiles)) return; try { - await ref.read(strategyProvider.notifier).loadFromFilePicker(); + await StrategyImportExportService(ref).loadFromFilePicker(); } on NewerVersionImportException catch (error, stackTrace) { AppErrorReporter.reportError( NewerVersionImportException.userMessage, @@ -114,14 +105,10 @@ class _FolderNavigatorState extends ConsumerState { } Future handleImportBackup() async { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; - } + if (!ensureFeatureAvailable(ref, PlatformFeature.importFiles)) return; try { - final result = await ref - .read(strategyProvider.notifier) - .importBackupFromFilePicker(); + final result = + await StrategyImportExportService(ref).importBackupFromFilePicker(); if (result.hasImports || result.issues.isNotEmpty) { final message = buildImportSummaryMessage(result); if (result.hasImports) { @@ -153,12 +140,9 @@ class _FolderNavigatorState extends ConsumerState { } Future handleExportLibrary() async { - if (kIsWeb) { - _showDesktopOnlyToast(); - return; - } + if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) return; try { - await ref.read(strategyProvider.notifier).exportLibrary(); + await StrategyImportExportService(ref).exportLibrary(); } catch (error, stackTrace) { AppErrorReporter.reportError( 'Failed to export library.', @@ -169,8 +153,22 @@ class _FolderNavigatorState extends ConsumerState { } } + /// Signed in, My Library writes to the cloud. Auth restores after the + /// first frame, so re-land on the tab's root once the cloud is reachable. + void _followCloudAvailability(bool available) { + if (!available) return; + if (ref.read(libraryTabProvider) != LibraryTab.library) return; + if (ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud) return; + if (ref.read(folderProvider) != null) return; + ref.read(libraryNavigationProvider).showLibrary(); + } + @override Widget build(BuildContext context) { + ref.listen( + isCloudWorkspaceAvailableProvider, + (_, available) => _followCloudAvailability(available), + ); ref.listen>(appUpdateStatusProvider, (_, next) { next.whenData((result) { @@ -195,10 +193,23 @@ class _FolderNavigatorState extends ConsumerState { final double height = MediaQuery.sizeOf(context).height - 90; final Size playAreaSize = Size(height * (16 / 9), height); CoordinateSystem(playAreaSize: playAreaSize); + final tab = ref.watch(libraryTabProvider); + final workspace = ref.watch(libraryWorkspaceProvider); + final isCloudWorkspace = workspace == LibraryWorkspace.cloud; final currentFolderId = ref.watch(folderProvider); final currentFolder = currentFolderId != null - ? ref.read(folderProvider.notifier).findFolderByID(currentFolderId) + ? isCloudWorkspace + ? ref.read(folderProvider.notifier).findCloudFolderByID( + currentFolderId, + ref.watch(cloudAllFoldersProvider).valueOrNull ?? const [], + ) + : ref + .read(folderProvider.notifier) + .findLocalFolderByID(currentFolderId) : null; + final canCreate = + tab == LibraryTab.library && !ref.watch(librarySignInRequiredProvider); + Future showCreateFolderDialog() async { await showDialog( context: context, @@ -208,8 +219,9 @@ class _FolderNavigatorState extends ConsumerState { ); } - /// Pick a map, then go straight to the editor: the view paints on the - /// chosen map at once and loads the new strategy inside itself. + /// Pick a map, then go straight to the editor. A local strategy paints + /// on the chosen map at once and loads inside the view; a cloud strategy + /// is already open once the server has created it. void showCreateDialog() async { final map = await showDialog( context: context, @@ -217,6 +229,8 @@ class _FolderNavigatorState extends ConsumerState { ); if (map == null || !context.mounted) return; + final isCloud = + ref.read(libraryWorkspaceProvider) == LibraryWorkspace.cloud; final StrategyData strategy; try { strategy = await ref @@ -224,7 +238,9 @@ class _FolderNavigatorState extends ConsumerState { .createNewStrategy(map: map); } catch (_) { Settings.showToast( - message: "Couldn't create strategy right now.", + message: isCloud + ? "Couldn't create cloud strategy right now. Please try logging in again." + : "Couldn't create strategy right now.", backgroundColor: Settings.tacticalVioletTheme.destructive, ); return; @@ -233,11 +249,14 @@ class _FolderNavigatorState extends ConsumerState { Navigator.push( context, - StrategyView.route( - initialStrategyId: strategy.id, - initialStrategyName: strategy.name, - initialMapValue: strategy.mapData, - ), + isCloud + ? StrategyView.route() + : StrategyView.route( + initialStrategyId: strategy.id, + initialStrategyName: strategy.name, + initialStrategySource: StrategySource.local, + initialMapValue: strategy.mapData, + ), ); } @@ -256,19 +275,25 @@ class _FolderNavigatorState extends ConsumerState { Expanded( child: ShadContextMenuRegion( controller: _backgroundMenuController, - items: [ - ShadContextMenuItem( - leading: const Icon(LucideIcons.folderPlus), - onPressed: showCreateFolderDialog, - child: const Text('Create Folder'), - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.filePlus), - onPressed: showCreateDialog, - child: const Text('Create Strategy'), - ), - ], + items: !canCreate + ? const [] + : [ + ShadContextMenuItem( + leading: const Icon(LucideIcons.folderPlus), + onPressed: showCreateFolderDialog, + child: const Text('Create Folder'), + ), + ShadContextMenuItem( + leading: const Icon(LucideIcons.filePlus), + onPressed: showCreateDialog, + child: const Text('Create Strategy'), + ), + ], + // Tabs are navigation, so the destination appears on the + // next frame. FolderContent owns loading transitions after + // the selected destination is already visible. child: FolderContent( + key: ValueKey('library-tab-body-${tab.name}'), folder: currentFolder, onCreateStrategy: showCreateDialog, ), @@ -277,6 +302,14 @@ class _FolderNavigatorState extends ConsumerState { ], ), ), + // Cloud sync status floats over the library's corner so it never + // pushes the grid. + if (tab != LibraryTab.community) + const Positioned( + right: 16, + bottom: 16, + child: CloudOutboxSummaryBanner(), + ), if (desktopUpdateController != null) DesktopUpdateDialogListener(controller: desktopUpdateController), ], @@ -284,16 +317,29 @@ class _FolderNavigatorState extends ConsumerState { } } -sealed class GridItem {} +/// Something the user can drag around the library grid. +sealed class GridItem { + /// The store the item lives in. Drops across stores are refused. + LibraryWorkspace get store; +} class FolderItem extends GridItem { final Folder folder; + @override + final LibraryWorkspace store; - FolderItem(this.folder); + FolderItem(this.folder, {required this.store}); } class StrategyItem extends GridItem { - final StrategyData strategy; + final String strategyId; + final StrategyData? strategy; + + StrategyItem.local(this.strategy) : strategyId = strategy!.id; - StrategyItem(this.strategy); + StrategyItem.cloud(this.strategyId) : strategy = null; + + @override + LibraryWorkspace get store => + strategy == null ? LibraryWorkspace.cloud : LibraryWorkspace.local; } diff --git a/lib/widgets/folder_pill.dart b/lib/widgets/folder_pill.dart deleted file mode 100644 index f5b8bbf4..00000000 --- a/lib/widgets/folder_pill.dart +++ /dev/null @@ -1,460 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/folder_icons.dart'; -import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/library_context_menu_provider.dart'; -import 'package:icarus/providers/pinned_items_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; -import 'package:icarus/widgets/drag_tilt_feedback.dart'; -import 'package:icarus/widgets/drop_insertion_indicator.dart'; -import 'package:icarus/widgets/folder_edit_dialog.dart'; -import 'package:icarus/widgets/folder_navigator.dart'; -import 'package:icarus/widgets/overflow_tooltip_text.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -const double _folderPillCornerRadius = 8; -const double _folderPillMenuButtonRadius = 6; - -class FolderPill extends ConsumerStatefulWidget { - const FolderPill({ - super.key, - required this.folder, - this.isDemo = false, - this.strategyCount, - this.folderCount, - }); - - final Folder folder; - final bool isDemo; - final int? strategyCount; - final int? folderCount; - - @override - ConsumerState createState() => _FolderPillState(); -} - -class _FolderPillState extends ConsumerState - with SingleTickerProviderStateMixin { - late AnimationController _animationController; - late Animation _scaleAnimation; - bool _isHovered = false; - bool _isMenuButtonHovered = false; - bool _menuButtonWasOpenOnPointerDown = false; - DropInsertionSide? _pinnedDropSide; - final ShadContextMenuController _contextMenuController = - ShadContextMenuController(); - final ShadContextMenuController _rightClickMenuController = - ShadContextMenuController(); - final DragTiltController _dragTiltController = DragTiltController(); - @override - void initState() { - super.initState(); - _animationController = AnimationController( - duration: const Duration(milliseconds: 150), - vsync: this, - ); - - _scaleAnimation = Tween( - begin: 1.0, - end: 1.03, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOut, - )); - } - - @override - void dispose() { - _animationController.dispose(); - _contextMenuController.dispose(); - _rightClickMenuController.dispose(); - super.dispose(); - } - - void _closeMenus() { - _contextMenuController.hide(); - _rightClickMenuController.hide(); - } - - void _handleMenuButtonPressed() { - if (_menuButtonWasOpenOnPointerDown) { - _menuButtonWasOpenOnPointerDown = false; - _closeMenus(); - return; - } - - dismissLibraryContextMenus(ref); - _contextMenuController.show(); - } - - Color get _folderColor => - widget.folder.customColor ?? - Folder.folderColorMap[widget.folder.color] ?? - Colors.grey; - - bool _isPinnedFolderReorderCandidate( - GridItem item, - Map pinned, - String id, - bool isPinned, - ) { - return item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id); - } - - @override - Widget build(BuildContext context) { - ref.listen( - libraryContextMenuDismissalProvider, - (_, __) => _closeMenus(), - ); - - final pinned = ref.watch(pinnedItemsProvider); - final id = widget.folder.id; - final isPinned = pinned.containsKey(id); - - return Draggable( - feedback: TiltDragFeedback( - controller: _dragTiltController, - opacity: 0.9, - child: _buildDragFeedback(), - ), - dragAnchorStrategy: pointerDragAnchorStrategy, - onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), - data: FolderItem(widget.folder), - child: DragTarget( - onWillAcceptWithDetails: (details) { - final item = details.data; - if (widget.isDemo) return false; - if (item is FolderItem) { - return item.folder.id != id && !_isParentFolder(item.folder.id); - } - return true; - }, - onMove: (details) { - final item = details.data; - final nextSide = - _isPinnedFolderReorderCandidate(item, pinned, id, isPinned) - ? resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - current: _pinnedDropSide, - ) - : null; - if (nextSide != _pinnedDropSide) { - setState(() => _pinnedDropSide = nextSide); - } - }, - onLeave: (_) { - if (_pinnedDropSide != null) { - setState(() => _pinnedDropSide = null); - } - }, - onAcceptWithDetails: (details) async { - if (widget.isDemo) return; - final item = details.data; - if (item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id)) { - final insertionSide = _pinnedDropSide ?? - resolveDropInsertionSide( - context: context, - globalOffset: details.offset, - ); - if (mounted) { - setState(() => _pinnedDropSide = null); - } - if (insertionSide == null) return; - - await ref.read(pinnedItemsProvider.notifier).movePin( - id: item.folder.id, - targetId: id, - insertAfterTarget: insertionSide == DropInsertionSide.after, - ); - return; - } - - if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategy.id, parentID: widget.folder.id); - } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( - folderID: item.folder.id, parentID: widget.folder.id); - } - }, - builder: (context, candidateData, rejectedData) { - final isPinnedDropTarget = candidateData.any( - (item) => - item is FolderItem && - item.folder.id != id && - isPinned && - pinned.containsKey(item.folder.id), - ); - final isDropTarget = candidateData.isNotEmpty; - final isMoveIntoFolderTarget = isDropTarget && !isPinnedDropTarget; - final isHoverActive = _isHovered && !isPinnedDropTarget; - return MouseRegion( - onEnter: (_) { - setState(() => _isHovered = true); - _animationController.forward(); - }, - onExit: (_) { - setState(() => _isHovered = false); - _animationController.reverse(); - }, - cursor: SystemMouseCursors.click, - child: ShadContextMenuRegion( - controller: _rightClickMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: GestureDetector( - onTap: () { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).updateID(widget.folder.id); - }, - child: AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - final dropSide = _pinnedDropSide; - final slotKey = dropSide == null - ? null - : dropInsertionSlotKey( - itemId: id, - side: dropSide, - pinnedOrder: pinnedIdsInManualOrder(pinned), - ); - return Transform.scale( - scale: isPinnedDropTarget ? 1 : _scaleAnimation.value, - child: Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 44, - padding: const EdgeInsets.only(left: 14, right: 6), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular( - _folderPillCornerRadius), - border: Border.all( - color: isMoveIntoFolderTarget - ? Colors.white - : (isHoverActive - ? Colors.white.withValues(alpha: 0.5) - : Colors.white.withValues(alpha: 0.15)), - width: isMoveIntoFolderTarget ? 2 : 1, - ), - boxShadow: [ - BoxShadow( - color: _folderColor.withValues(alpha: 0.3), - blurRadius: isHoverActive ? 8 : 4, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: 140), - child: OverflowTooltipText( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ), - if (isPinned) ...[ - const SizedBox(width: 6), - Icon( - LucideIcons.pin, - color: Colors.white.withValues(alpha: 0.78), - size: 14, - ), - ], - const SizedBox(width: 4), - _buildMenuButton(isPinned: isPinned), - ], - ), - ), - if (dropSide != null && slotKey != null) - Positioned.fill( - child: DropInsertionIndicator( - key: ValueKey(slotKey), - slotKey: slotKey, - side: dropSide, - gap: 18, - ), - ), - ], - ), - ); - }, - ), - ), - ), - ); - }, - ), - ); - } - - Widget _buildMenuButton({required bool isPinned}) { - final backgroundAlpha = _isMenuButtonHovered ? 0.16 : 0.04; - final iconAlpha = _isMenuButtonHovered ? 0.96 : 0.74; - - return ShadContextMenuRegion( - controller: _contextMenuController, - items: _buildMenuItems(isPinned: isPinned), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _isMenuButtonHovered = true), - onExit: (_) => setState(() => _isMenuButtonHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: backgroundAlpha), - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - ), - child: Listener( - onPointerDown: (_) { - _menuButtonWasOpenOnPointerDown = _contextMenuController.isOpen; - }, - child: InkWell( - borderRadius: BorderRadius.circular(_folderPillMenuButtonRadius), - mouseCursor: SystemMouseCursors.click, - hoverColor: Colors.transparent, - splashColor: Colors.white.withValues(alpha: 0.12), - highlightColor: Colors.white.withValues(alpha: 0.08), - onTap: _handleMenuButtonPressed, - child: Icon( - LucideIcons.ellipsisVertical, - color: Colors.white.withValues(alpha: iconAlpha), - size: 18, - ), - ), - ), - ), - ), - ); - } - - List _buildMenuItems({required bool isPinned}) { - final id = widget.folder.id; - return [ - ShadContextMenuItem( - leading: Icon(isPinned ? LucideIcons.pinOff : LucideIcons.pin), - child: Text(isPinned ? 'Unpin' : 'Pin'), - onPressed: () { - _closeMenus(); - if (widget.isDemo) return; - ref.read(pinnedItemsProvider.notifier).togglePin(id); - }, - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.pencil), - child: const Text('Edit'), - onPressed: () async { - _closeMenus(); - if (widget.isDemo) return; - await showDialog( - context: context, - builder: (context) { - return FolderEditDialog(folder: widget.folder); - }, - ); - }, - ), - ShadContextMenuItem( - leading: const Icon(LucideIcons.upload), - child: const Text('Export'), - onPressed: () async { - _closeMenus(); - await ref - .read(strategyProvider.notifier) - .exportFolder(widget.folder.id); - }, - ), - ShadContextMenuItem( - leading: Icon(LucideIcons.trash2, - color: Settings.tacticalVioletTheme.destructive), - child: Text('Delete', - style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), - onPressed: () async { - _closeMenus(); - ConfirmAlertDialog.show( - context: context, - title: - "Are you sure you want to delete '${widget.folder.name}' folder?", - content: - "This will also delete all strategies and subfolders within it.", - confirmText: "Delete", - isDestructive: true, - ).then((confirmed) { - if (confirmed) { - if (widget.isDemo) return; - ref.read(folderProvider.notifier).deleteFolder(widget.folder.id); - } - }); - }, - ), - ]; - } - - Widget _buildDragFeedback() { - return Container( - height: 44, - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: _folderColor, - borderRadius: BorderRadius.circular(_folderPillCornerRadius), - border: Border.all(color: Colors.white, width: 2), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - FolderIconView( - iconId: widget.folder.iconId, - color: Colors.white, - size: 20, - ), - const SizedBox(width: 10), - Text( - widget.folder.name, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ); - } - - bool _isParentFolder(String folderId) { - String? currentParentId = widget.folder.parentID; - while (currentParentId != null) { - if (currentParentId == folderId) return true; - final parentFolder = - ref.read(folderProvider.notifier).findFolderByID(currentParentId); - currentParentId = parentFolder?.parentID; - } - return false; - } -} diff --git a/lib/widgets/global_shortcuts.dart b/lib/widgets/global_shortcuts.dart index 8659e9c5..bbb89998 100644 --- a/lib/widgets/global_shortcuts.dart +++ b/lib/widgets/global_shortcuts.dart @@ -6,6 +6,7 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/duplicate_drag_modifier_provider.dart'; import 'package:icarus/providers/hovered_delete_target_provider.dart'; @@ -17,6 +18,8 @@ import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/widgets/delete_helpers.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:uuid/uuid.dart'; class GlobalShortcuts extends ConsumerStatefulWidget { @@ -34,6 +37,16 @@ class _GlobalShortcutsState extends ConsumerState return false; } + // Catches shortcuts for the whole app; it is never a Tab stop. Skipping + // traversal also keeps it out of Flutter's reading-order sort: on web that + // sort runs when the page first takes focus, before the first layout, and + // measuring this node beside the navigator threw "RenderBox was not laid + // out" on every load (flutter/flutter#191508). + final FocusNode _focusNode = FocusNode( + debugLabel: 'global-shortcuts', + skipTraversal: true, + ); + @override void initState() { super.initState(); @@ -45,6 +58,7 @@ class _GlobalShortcutsState extends ConsumerState void dispose() { HardwareKeyboard.instance.removeHandler(_handleModifierKeyEvent); WidgetsBinding.instance.removeObserver(this); + _focusNode.dispose(); super.dispose(); } @@ -61,209 +75,226 @@ class _GlobalShortcutsState extends ConsumerState @override Widget build(BuildContext context) { - return Focus( - autofocus: true, - // canRequestFocus: true, - child: Shortcuts( - shortcuts: ShortcutInfo.globalShortcutsFor( - ref.watch(appPreferencesProvider).customShortcutBindings, - ), - child: Actions( - actions: { - NavigationActionIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); + final capabilities = ref.watch(currentStrategyCapabilitiesProvider); + + // The focus node sits inside Shortcuts, so shortcuts still fire when + // the node itself holds focus. With the web semantics tree on, a click + // on an empty part of the page focuses it. + return Shortcuts( + shortcuts: ShortcutInfo.globalShortcutsFor( + ref.watch(appPreferencesProvider).customShortcutBindings, + ), + child: Actions( + actions: { + NavigationActionIntent: CallbackAction( + onInvoke: (intent) { + _dismissDeleteMenu(); + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.navigation); + return null; + }, + ), + ToggleAgentFilterIntent: CallbackAction( + onInvoke: (intent) { + _dismissDeleteMenu(); + ref.read(agentFilterProvider.notifier).toggleAllOnMap(); + return null; + }, + ), + ForwardPageIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref.read(strategyProvider.notifier).forwardPage(); + return null; + }, + ), + BackwardPageIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref.read(strategyProvider.notifier).backwardPage(); + return null; + }, + ), + SwitchSideIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref + .read(strategyProvider.notifier) + .switchSide(allPages: true); + return null; + }, + ), + SwitchSideThisPageIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await ref + .read(strategyProvider.notifier) + .switchSide(allPages: false); + return null; + }, + ), + AddPageIntent: CallbackAction( + onInvoke: (intent) async { + if (!capabilities.canAddPage) return null; + _dismissDeleteMenu(); + await ref.read(strategyProvider.notifier).addPage(); + return null; + }, + ), + ToggleLineupIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + if (!ensureFeatureAvailable(ref, PlatformFeature.addLineups)) { + return null; + } + _dismissDeleteMenu(); + if (ref.read(interactionStateProvider) == + InteractionState.lineUpPlacing) { ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); - return null; - }, - ), - ToggleAgentFilterIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); - ref.read(agentFilterProvider.notifier).toggleAllOnMap(); - return null; - }, - ), - ForwardPageIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - await ref.read(strategyProvider.notifier).forwardPage(); - return null; - }, - ), - BackwardPageIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - await ref.read(strategyProvider.notifier).backwardPage(); - return null; - }, - ), - SwitchSideIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - await ref - .read(strategyProvider.notifier) - .switchSide(allPages: true); - return null; - }, - ), - SwitchSideThisPageIntent: - CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - await ref - .read(strategyProvider.notifier) - .switchSide(allPages: false); - return null; - }, - ), - AddPageIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - await ref.read(strategyProvider.notifier).addPage(); - return null; - }, - ), - ToggleLineupIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); - if (ref.read(interactionStateProvider) == - InteractionState.lineUpPlacing) { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.navigation); - } else { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.lineUpPlacing); - } - return null; - }, - ), - OpenInAppDebugIntent: CallbackAction( - onInvoke: (intent) async { + } else { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.lineUpPlacing); + } + return null; + }, + ), + OpenInAppDebugIntent: CallbackAction( + onInvoke: (intent) async { + _dismissDeleteMenu(); + await AppErrorReporter.openDebugLog(); + return null; + }, + ), + ContextualDeleteIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + final hoveredTarget = ref.read(hoveredDeleteTargetProvider); + if (hoveredTarget != null) { _dismissDeleteMenu(); - await AppErrorReporter.openDebugLog(); + ref.read(hoveredDeleteTargetProvider.notifier).state = null; + deleteHoveredTarget(ref, hoveredTarget); return null; - }, - ), - ContextualDeleteIntent: CallbackAction( - onInvoke: (intent) { - final hoveredTarget = ref.read(hoveredDeleteTargetProvider); - if (hoveredTarget != null) { - _dismissDeleteMenu(); - ref.read(hoveredDeleteTargetProvider.notifier).state = null; - deleteHoveredTarget(ref, hoveredTarget); - return null; - } - - final deleteMenuState = ref.read(deleteMenuProvider); - if (deleteMenuState.isOpenRequested) { - _dismissDeleteMenu(); - return null; - } + } - ref.read(deleteMenuProvider.notifier).requestOpen( - reason: DeleteMenuOpenReason.keyboard, - ); - return null; - }, - ), - UndoActionIntent: CallbackAction( - onInvoke: (intent) { + final deleteMenuState = ref.read(deleteMenuProvider); + if (deleteMenuState.isOpenRequested) { _dismissDeleteMenu(); - ref.read(actionProvider.notifier).undoAction(); return null; - }, - ), - AddedTextIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); - const uuid = Uuid(); - final placementCenter = ref.read(placementCenterProvider); - const defaultTextWidth = 185.0; - const defaultTextHeight = 40.0; - final centeredTopLeft = placementCenter - - const Offset(defaultTextWidth / 2, defaultTextHeight / 2); + } - ref.read(textProvider.notifier).addText( - PlacedText( - position: centeredTopLeft, - id: uuid.v4(), - size: defaultTextWidth, - fontSize: 16, - sizeVersion: worldSizedMediaVersion, - ), - ); - return null; - }, - ), - ToggleDrawingIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); - if (ref.read(interactionStateProvider) == - InteractionState.drawing) { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.navigation); - } else { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.drawing); - } - return null; - }, - ), - ToggleErasingIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - if (ref.read(interactionStateProvider) == - InteractionState.erasing) { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.navigation); - } else { - ref - .read(interactionStateProvider.notifier) - .update(InteractionState.erasing); - await ref.read(penProvider.notifier).buildCursors(); - } - return null; - }, - ), - RedoActionIntent: CallbackAction( - onInvoke: (intent) { - _dismissDeleteMenu(); - ref.read(actionProvider.notifier).redoAction(); - return null; - }, - ), - SaveStrategyIntent: CallbackAction( - onInvoke: (intent) async { - _dismissDeleteMenu(); - final strategyId = ref.read(strategyProvider).id; - try { - await ref.read(strategyProvider.notifier).forceSaveNow( - strategyId, - ); - if (!mounted) return null; - Settings.showToast( - message: 'File saved', - backgroundColor: Colors.green, + ref.read(deleteMenuProvider.notifier).requestOpen( + reason: DeleteMenuOpenReason.keyboard, ); - } catch (_) { - if (!mounted) return null; - Settings.showToast( - message: 'Save failed', - backgroundColor: Colors.red, + return null; + }, + ), + UndoActionIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + ref.read(actionProvider.notifier).undoAction(); + return null; + }, + ), + AddedTextIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + const uuid = Uuid(); + final placementCenter = ref.read(placementCenterProvider); + const defaultTextWidth = 185.0; + const defaultTextHeight = 40.0; + final centeredTopLeft = placementCenter - + const Offset(defaultTextWidth / 2, defaultTextHeight / 2); + + ref.read(textProvider.notifier).addText( + PlacedText( + position: centeredTopLeft, + id: uuid.v4(), + size: defaultTextWidth, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + ), ); - } - return null; - }, - ), - }, + return null; + }, + ), + ToggleDrawingIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + if (ref.read(interactionStateProvider) == + InteractionState.drawing) { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.navigation); + } else { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.drawing); + } + return null; + }, + ), + ToggleErasingIntent: CallbackAction( + onInvoke: (intent) async { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + if (ref.read(interactionStateProvider) == + InteractionState.erasing) { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.navigation); + } else { + ref + .read(interactionStateProvider.notifier) + .update(InteractionState.erasing); + await ref.read(penProvider.notifier).buildCursors(); + } + return null; + }, + ), + RedoActionIntent: CallbackAction( + onInvoke: (intent) { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + ref.read(actionProvider.notifier).redoAction(); + return null; + }, + ), + SaveStrategyIntent: CallbackAction( + onInvoke: (intent) async { + if (!capabilities.canEditPages) return null; + _dismissDeleteMenu(); + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return null; + try { + await ref.read(strategyProvider.notifier).forceSaveNow( + strategyId, + ); + if (!mounted) return null; + Settings.showToast( + message: 'File saved', + backgroundColor: Colors.green, + ); + } catch (_) { + if (!mounted) return null; + Settings.showToast( + message: 'Save failed', + backgroundColor: Colors.red, + ); + } + return null; + }, + ), + }, + child: Focus( + focusNode: _focusNode, + autofocus: true, child: widget.child, ), ), diff --git a/lib/widgets/ica_drop_target.dart b/lib/widgets/ica_drop_target.dart index 7dfc4fd2..7b3163c1 100644 --- a/lib/widgets/ica_drop_target.dart +++ b/lib/widgets/ica_drop_target.dart @@ -2,7 +2,7 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/services/app_error_reporter.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -96,9 +96,10 @@ class _CustomDropTargetState extends ConsumerState { }); } try { - final result = await ref - .read(strategyProvider.notifier) - .loadFromFileDrop(details.files); + final result = + await StrategyImportExportService(ref).loadFromFileDrop( + details.files, + ); if (result.hasImports || result.issues.isNotEmpty) { final message = buildImportSummaryMessage(result); diff --git a/lib/widgets/image_drop_target.dart b/lib/widgets/image_drop_target.dart index ec65f673..7e98dca3 100644 --- a/lib/widgets/image_drop_target.dart +++ b/lib/widgets/image_drop_target.dart @@ -1,9 +1,11 @@ import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; class ImageDropTarget extends ConsumerStatefulWidget { @@ -19,25 +21,34 @@ class _ImageDropTargetState extends ConsumerState { @override Widget build(BuildContext context) { + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); + // Only invite a drop that can land. + final canDrop = canEditPages && + ref.watch(platformPolicyProvider).supports(PlatformFeature.fileDrop); + return DropTarget( onDragEntered: (details) { + if (!canDrop) return; setState(() { isDragging = true; }); }, onDragExited: (details) { + if (!canDrop) return; setState(() { isDragging = false; }); }, onDragDone: (details) async { - if (kIsWeb) { - Settings.showToast( - message: 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); + if (!ref.read(currentStrategyCapabilitiesProvider).canEditPages) { return; } + if (!ensureFeatureAvailable(ref, PlatformFeature.fileDrop)) return; + if (!ensureFeatureAvailable(ref, PlatformFeature.addImages)) return; isDragging = false; final files = details.files; @@ -48,9 +59,13 @@ class _ImageDropTargetState extends ConsumerState { if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp'] .contains(rawExtension)) { final fileExtension = '.$rawExtension'; + final strategyState = ref.read(strategyProvider); await ref.read(placedImageProvider.notifier).addImage( - imageBytes: await file.readAsBytes(), - fileExtension: fileExtension); + imageBytes: await file.readAsBytes(), + strategyId: strategyState.strategyId, + strategySource: strategyState.source, + fileExtension: fileExtension, + ); } } }, diff --git a/lib/widgets/library_breadcrumb.dart b/lib/widgets/library_breadcrumb.dart index 51e8522a..28aa2f5a 100644 --- a/lib/widgets/library_breadcrumb.dart +++ b/lib/widgets/library_breadcrumb.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/folder_navigator.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -/// Where the user is inside a folder tree. Shown only inside a folder; at the -/// library's root the tab itself says where you are. +/// Where the user is inside a folder tree. Shown only inside a folder; at a +/// tab's root the tab itself says where you are. class LibraryBreadcrumb extends ConsumerWidget { const LibraryBreadcrumb({super.key, required this.folder}); @@ -15,16 +19,20 @@ class LibraryBreadcrumb extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final folders = ref.read(folderProvider.notifier); - final pathFolders = folders - .getFullPathIDs(folder) - .map(folders.findFolderByID) - .whereType() - .toList(growable: false); + final tab = ref.watch(libraryTabProvider); + final store = ref.watch(libraryWorkspaceProvider); + final pathFolders = _pathFolders(ref, store); final parent = pathFolders.length >= 2 ? pathFolders[pathFolders.length - 2] : null; - void goToRoot() => ref.read(folderProvider.notifier).updateID(null); + void goToRoot() { + final navigation = ref.read(libraryNavigationProvider); + if (tab == LibraryTab.shared) { + navigation.showShared(); + } else { + navigation.showLibrary(); + } + } // Same card as the editor toolbar, so the path reads as hardware on the // bench instead of text floating on the dot grid. @@ -63,12 +71,16 @@ class LibraryBreadcrumb extends ConsumerWidget { children: [ FolderTab( folder: null, - label: 'My Library', + label: tab == LibraryTab.shared + ? 'Shared with Me' + : 'My Library', + store: store, onOpen: goToRoot, ), for (int i = 0; i < pathFolders.length; i++) FolderTab( folder: pathFolders[i], + store: store, isActive: i == pathFolders.length - 1, onOpen: () => ref .read(folderProvider.notifier) @@ -81,14 +93,40 @@ class LibraryBreadcrumb extends ConsumerWidget { ), ); } + + List _pathFolders(WidgetRef ref, LibraryWorkspace store) { + if (store == LibraryWorkspace.cloud) { + final cloudFolders = + (ref.watch(cloudAllFoldersProvider).valueOrNull ?? const []) + .map((entry) => entry.folder) + .toList(growable: false); + final path = []; + Folder? current = folder; + while (current != null) { + path.insert(0, current); + final parentId = current.parentID; + current = parentId == null + ? null + : cloudFolders.where((item) => item.id == parentId).firstOrNull; + } + return path; + } + final folders = ref.read(folderProvider.notifier); + return folders + .getFullPathIDs(folder) + .map(folders.findLocalFolderByID) + .whereType() + .toList(growable: false); + } } /// One crumb. Also a drop target: dragging a strategy or folder onto it moves -/// the item there. +/// the item there, within the same store. class FolderTab extends ConsumerWidget { const FolderTab({ super.key, required this.folder, + required this.store, required this.onOpen, this.label, this.isActive = false, @@ -96,6 +134,7 @@ class FolderTab extends ConsumerWidget { /// Null for the root crumb. final Folder? folder; + final LibraryWorkspace store; final VoidCallback onOpen; final String? label; final bool isActive; @@ -107,17 +146,22 @@ class FolderTab extends ConsumerWidget { normalColor: isActive ? Settings.tacticalVioletTheme.foreground : null, onPressed: onOpen, child: DragTarget( - onAcceptWithDetails: (details) { + onWillAcceptWithDetails: (details) => details.data.store == store, + onAcceptWithDetails: (details) async { final item = details.data; if (item is StrategyItem) { - ref.read(strategyProvider.notifier).moveToFolder( - strategyID: item.strategy.id, + await ref.read(strategyProvider.notifier).moveToFolder( + strategyID: item.strategyId, parentID: folder?.id, + source: item.strategy == null + ? StrategySource.cloud + : StrategySource.local, ); } else if (item is FolderItem) { - ref.read(folderProvider.notifier).moveToFolder( + await ref.read(folderProvider.notifier).moveToFolder( folderID: item.folder.id, parentID: folder?.id, + workspace: store, ); } }, diff --git a/lib/widgets/library_entries.dart b/lib/widgets/library_entries.dart new file mode 100644 index 00000000..52ef4932 --- /dev/null +++ b/lib/widgets/library_entries.dart @@ -0,0 +1,124 @@ +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/folder_card.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +/// One folder in the library grid, tagged with the store it lives in so the +/// pill knows which provider path to use for moves, edits, and opening. +class LibraryFolderRow { + const LibraryFolderRow({ + required this.folder, + required this.store, + required this.lastUpdated, + required this.card, + this.cloudRole, + }); + + final Folder folder; + final LibraryWorkspace store; + + /// What the folder card shows: contents for local folders, the subfolder + /// count alone for cloud folders (their strategies load per folder). + final FolderCardViewData card; + + /// The signed-in user's role on a cloud folder; null for local folders. + final String? cloudRole; + + /// Newest edit inside the folder tree, used for the "Date updated" sort. + /// Cloud folders do not carry this yet and fall back to their creation date. + final DateTime lastUpdated; + + String get id => folder.id; +} + +/// One strategy in the library grid, from either store. +class LibraryStrategyRow { + LibraryStrategyRow.local(StrategyData strategy, + {required this.showDeviceBadge}) + : local = strategy, + cloud = null; + + LibraryStrategyRow.cloud(CloudStrategyEntry entry) + : cloud = entry, + local = null, + showDeviceBadge = false; + + final StrategyData? local; + final CloudStrategyEntry? cloud; + + /// True for local strategies while the cloud is reachable, so the tile can + /// say it is not synced. + final bool showDeviceBadge; + + String get id => local?.id ?? cloud!.strategy.id; + String get name => local?.name ?? cloud!.strategy.name; + DateTime get createdAt => local?.createdAt ?? cloud!.strategy.createdAt; + DateTime get lastEdited => local?.lastEdited ?? cloud!.strategy.lastEdited; +} + +/// Combines both stores for the My Library root. A folder present in both +/// (a migrated one keeps its id) shows once, as its cloud copy. +List mergeLibraryFolders({ + required List local, + required List cloud, +}) { + final cloudIds = {for (final row in cloud) row.id}; + return [ + ...cloud, + for (final row in local) + if (!cloudIds.contains(row.id)) row, + ]; +} + +/// Same rule as [mergeLibraryFolders], for strategies. +List mergeLibraryStrategies({ + required List local, + required List cloud, +}) { + final cloudIds = {for (final row in cloud) row.id}; + return [ + ...cloud, + for (final row in local) + if (!cloudIds.contains(row.id)) row, + ]; +} + +List sortLibraryFolders( + List rows, + StrategyFilterState filter, +) { + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + final sorted = [...rows]; + sorted.sort((a, b) { + final result = switch (filter.sortBy) { + SortBy.alphabetical => + a.folder.name.toLowerCase().compareTo(b.folder.name.toLowerCase()), + SortBy.dateCreated => + a.folder.dateCreated.compareTo(b.folder.dateCreated), + SortBy.dateUpdated => a.lastUpdated.compareTo(b.lastUpdated), + }; + if (result != 0) return direction * result; + return a.id.compareTo(b.id); + }); + return sorted; +} + +List sortLibraryStrategies( + List rows, + StrategyFilterState filter, +) { + final direction = filter.sortOrder == SortOrder.ascending ? 1 : -1; + final sorted = [...rows]; + sorted.sort((a, b) { + final result = switch (filter.sortBy) { + SortBy.alphabetical => + a.name.toLowerCase().compareTo(b.name.toLowerCase()), + SortBy.dateCreated => a.createdAt.compareTo(b.createdAt), + SortBy.dateUpdated => a.lastEdited.compareTo(b.lastEdited), + }; + return direction * result; + }); + return sorted; +} diff --git a/lib/widgets/library_title_strip.dart b/lib/widgets/library_title_strip.dart index 5249eb02..d1b5079a 100644 --- a/lib/widgets/library_title_strip.dart +++ b/lib/widgets/library_title_strip.dart @@ -1,12 +1,18 @@ -import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/account_avatar.dart'; import 'package:icarus/widgets/custom_search_field.dart'; -import 'package:icarus/widgets/demo_tag.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/strip_status_icons.dart'; +import 'package:icarus/widgets/web_beta_tag.dart'; import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -15,17 +21,18 @@ const double _controlHeight = 28; const double _tabGap = 4; // Matches the ghost button's own radius so the rim hugs its hover shape. const double _tabRadius = 6; -// Action menus hug their labels. +// Action menus hug their labels; the account menu keeps room for email text. const double _sortMenuWidth = 168; const double _newMenuWidth = 140; +const double _accountMenuWidth = 200; const double _menuItemHorizontalPadding = 8; const double _menuIconWidth = 18; const double _menuItemGap = 8; const double _menuLabelLeftInset = _menuItemHorizontalPadding + _menuIconWidth + _menuItemGap; -/// The library's only chrome: tabs on the left, search / sort / New on the -/// right, all inside the window's title strip. +/// The library's only chrome: tabs on the left, What's new / search / sort / +/// New / account on the right, all inside the window's title strip. class LibraryTitleStrip extends ConsumerStatefulWidget { const LibraryTitleStrip({ super.key, @@ -49,16 +56,33 @@ class LibraryTitleStrip extends ConsumerStatefulWidget { class _LibraryTitleStripState extends ConsumerState { final ShadPopoverController _sortController = ShadPopoverController(); final ShadPopoverController _newController = ShadPopoverController(); + final ShadPopoverController _accountController = ShadPopoverController(); @override void dispose() { _sortController.dispose(); _newController.dispose(); + _accountController.dispose(); super.dispose(); } + void _showAuthDialog() { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + } + @override Widget build(BuildContext context) { + final tab = ref.watch(libraryTabProvider); + final cloudAvailable = ref.watch(isCloudWorkspaceAvailableProvider); + final isWebBeta = ref.watch(platformPolicyProvider).isWebBeta; + // Signed out on the web beta, My Library holds only the sign-in action, + // so there is nothing to search, sort, or create. + final signInRequired = ref.watch(librarySignInRequiredProvider); + final navigation = ref.read(libraryNavigationProvider); + return AppWindowStrip( child: Row( children: [ @@ -69,20 +93,27 @@ class _LibraryTitleStripState extends ConsumerState { icon: LucideIcons.folder, label: 'My Library', semanticsLabel: 'My Library', - selected: true, - onTap: () => ref.read(folderProvider.notifier).updateID(null), + selected: tab == LibraryTab.library, + onTap: navigation.showLibrary, ), const SizedBox(width: _tabGap), - // Shared and Community have nowhere to go yet; they hold their - // place so the library's shape does not move when they land. _TabButton( key: const ValueKey('library-tab-shared'), icon: LucideIcons.users, label: 'Shared', semanticsLabel: 'Shared library', - selected: false, - comingSoon: true, - onTap: () => _comingSoon('Shared libraries'), + selected: tab == LibraryTab.shared, + dimmed: !cloudAvailable, + onTap: () { + if (navigation.showShared()) return; + // Signed in but the cloud is unreachable: My Library carries + // the retry. Never ask for a login over an existing session. + if (ref.read(authProvider).isAuthenticated) { + navigation.showLibrary(); + } else { + _showAuthDialog(); + } + }, ), const SizedBox(width: _tabGap), _TabButton( @@ -90,13 +121,12 @@ class _LibraryTitleStripState extends ConsumerState { icon: LucideIcons.globe, label: 'Community', semanticsLabel: 'Community library', - selected: false, - comingSoon: true, - onTap: () => _comingSoon('The community library'), + selected: tab == LibraryTab.community, + onTap: navigation.showCommunity, ), - if (kIsWeb) ...[ + if (isWebBeta) ...[ const SizedBox(width: 8), - const DemoTag(), + const WebBetaTag(), ], const Expanded( child: WindowDragArea( @@ -106,33 +136,42 @@ class _LibraryTitleStripState extends ConsumerState { ), const WhatsNewIcon(), const SizedBox(width: 4), - const SizedBox( - height: _controlHeight, - child: SearchTextField( - key: ValueKey('library-search'), - collapsedWidth: _controlHeight, - expandedWidth: 220, - compact: true, - hintText: 'Search', + if (tab != LibraryTab.community && !signInRequired) ...[ + const SizedBox( + height: _controlHeight, + child: SearchTextField( + key: ValueKey('library-search'), + collapsedWidth: _controlHeight, + expandedWidth: 220, + compact: true, + hintText: 'Search', + ), ), - ), - const SizedBox(width: 4), - _buildSortMenu(), - const SizedBox(width: 8), - _buildNewMenu(), + const SizedBox(width: 4), + _buildSortMenu(), + const SizedBox(width: 8), + // Nothing is created in Shared; its New is adding what someone + // shared with you. + if (tab == LibraryTab.shared) + ShadButton.secondary( + key: const ValueKey('cloud-add-shared-item'), + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 10), + onPressed: () => showAddSharedItemDialog(context), + leading: const Icon(LucideIcons.link, size: 14), + child: const Text('Add by Link or Code'), + ) + else + _buildNewMenu(), + const SizedBox(width: 8), + ], + _buildAccount(), const SizedBox(width: 10), ], ), ); } - void _comingSoon(String what) { - Settings.showToast( - message: "$what aren't ready yet. Coming very soon.", - backgroundColor: Settings.tacticalVioletTheme.primary, - ); - } - Widget _buildSortMenu() { final filter = ref.watch(strategyFilterProvider); final isAscending = filter.sortOrder == SortOrder.ascending; @@ -208,7 +247,9 @@ class _LibraryTitleStripState extends ConsumerState { ); Widget _buildNewMenu() { - const showLibraryTools = !kIsWeb; + final policy = ref.watch(platformPolicyProvider); + final canImport = policy.supports(PlatformFeature.importFiles); + final canExport = policy.supports(PlatformFeature.exportFiles); return ShadPopover( controller: _newController, padding: const EdgeInsets.all(6), @@ -230,8 +271,8 @@ class _LibraryTitleStripState extends ConsumerState { label: 'New Folder', onPressed: widget.onCreateFolder, ), - if (showLibraryTools) ...[ - const _MenuDivider(), + if (canImport || canExport) const _MenuDivider(), + if (canImport) ...[ _MenuItem( menu: _newController, icon: LucideIcons.fileDown, @@ -244,13 +285,14 @@ class _LibraryTitleStripState extends ConsumerState { label: 'Import Backup', onPressed: widget.onImportBackup, ), + ], + if (canExport) _MenuItem( menu: _newController, icon: LucideIcons.archive, label: 'Export Library', onPressed: widget.onExportLibrary, ), - ], ], ), ), @@ -291,6 +333,107 @@ class _LibraryTitleStripState extends ConsumerState { ), ); } + + Widget _buildAccount() { + final auth = ref.watch(authProvider); + if (auth.isLoading) { + return const SizedBox( + key: ValueKey('library-account-action'), + width: _controlHeight, + height: _controlHeight, + child: Center( + child: SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + if (!auth.isAuthenticated) { + return Semantics( + label: 'Log in to Icarus', + button: true, + onTap: _showAuthDialog, + child: ShadButton.secondary( + key: const ValueKey('library-account-action'), + height: _controlHeight, + padding: const EdgeInsets.symmetric(horizontal: 10), + onPressed: _showAuthDialog, + child: const Text('Log In'), + ), + ); + } + return ShadPopover( + controller: _accountController, + padding: const EdgeInsets.all(6), + anchor: const ShadAnchor( + offset: Offset(0, 6), + childAlignment: Alignment.topRight, + overlayAlignment: Alignment.bottomRight, + ), + popover: (context) => SizedBox( + width: _accountMenuWidth, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(10, 6, 10, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + auth.displayName, + style: const TextStyle(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + if (auth.user?.email case final email?) + Text( + email, + style: TextStyle( + fontSize: 12, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const _MenuDivider(), + _MenuItem( + menu: _accountController, + icon: LucideIcons.logOut, + label: 'Sign Out', + onPressed: _confirmSignOut, + ), + ], + ), + ), + child: Semantics( + label: 'Account for ${auth.displayName}', + button: true, + onTap: _accountController.toggle, + child: ShadButton.ghost( + key: const ValueKey('library-account-action'), + width: _controlHeight, + height: _controlHeight, + padding: EdgeInsets.zero, + onPressed: _accountController.toggle, + child: AccountAvatar( + radius: 12, + backgroundColor: Settings.tacticalVioletTheme.secondary, + avatarUrl: auth.avatarUrl, + fallback: const Icon(LucideIcons.user, size: 14), + ), + ), + ), + ); + } + + Future _confirmSignOut() async { + await ref.read(guardedSignOutRequestProvider)(context); + } } class _TabButton extends StatelessWidget { @@ -300,8 +443,8 @@ class _TabButton extends StatelessWidget { required this.label, required this.semanticsLabel, required this.selected, - this.onTap, - this.comingSoon = false, + required this.onTap, + this.dimmed = false, }); final IconData icon; @@ -309,23 +452,23 @@ class _TabButton extends StatelessWidget { final String semanticsLabel; final bool selected; - /// Dims the tab and says so on hover; the tap should explain itself too. - final bool comingSoon; + /// Signed out, Shared still answers a tap (with the sign-in dialog) but + /// reads as unavailable. + final bool dimmed; - final VoidCallback? onTap; + final VoidCallback onTap; @override Widget build(BuildContext context) { const theme = Settings.tacticalVioletTheme; final foreground = selected ? theme.foreground : theme.mutedForeground; - final button = Semantics( + return Semantics( label: semanticsLabel, button: true, selected: selected, - enabled: onTap != null, onTap: onTap, child: Opacity( - opacity: comingSoon ? 0.45 : 1, + opacity: dimmed ? 0.45 : 1, child: DecoratedBox( decoration: selected ? Settings.raisedSurface(_tabRadius) @@ -334,7 +477,6 @@ class _TabButton extends StatelessWidget { height: _controlHeight, padding: const EdgeInsets.symmetric(horizontal: 8), gap: 6, - cursor: onTap == null ? SystemMouseCursors.basic : null, backgroundColor: selected ? Colors.transparent : null, hoverBackgroundColor: selected ? Colors.transparent : null, foregroundColor: foreground, @@ -349,11 +491,6 @@ class _TabButton extends StatelessWidget { ), ), ); - if (!comingSoon) return button; - return ShadTooltip( - builder: (context) => const Text('Coming soon'), - child: button, - ); } } diff --git a/lib/widgets/line_up_line_painter.dart b/lib/widgets/line_up_line_painter.dart index f643c650..65b19f8b 100644 --- a/lib/widgets/line_up_line_painter.dart +++ b/lib/widgets/line_up_line_painter.dart @@ -10,8 +10,8 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; final lineUpCanvasResizeProvider = NotifierProvider( - LineUpCanvasResizeNotifier.new, - ); + LineUpCanvasResizeNotifier.new, +); class LineUpCanvasResizeNotifier extends Notifier { @override diff --git a/lib/widgets/line_up_media_carousel.dart b/lib/widgets/line_up_media_carousel.dart index 6f1a11a6..a6769881 100644 --- a/lib/widgets/line_up_media_carousel.dart +++ b/lib/widgets/line_up_media_carousel.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -7,12 +6,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/settings.dart'; -import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; import 'package:icarus/widgets/youtube_view.dart'; -import 'package:path/path.dart' as path; +import 'package:icarus/widgets/lineup_editors_notice.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; /// Fullscreen viewer for one lineup's media, with delete and edit actions. @@ -51,13 +49,17 @@ class LineUpMediaCarousel extends ConsumerWidget { youtubeLink: link.youtubeLink, padding: const EdgeInsets.all(56.0), ), + // One row, so the notice gives way to the actions however + // narrow the window. Positioned( top: 24, + left: 24, right: 24, child: SafeArea( child: Row( spacing: 8, children: [ + Expanded(child: LineUpEditorsNotice(itemId: linkId)), ShadIconButton.destructive( icon: const Icon(LucideIcons.trash2), decoration: ShadDecoration( @@ -122,8 +124,6 @@ class LineUpMediaPages extends ConsumerStatefulWidget { class _LineUpMediaPagesState extends ConsumerState with AutomaticKeepAliveClientMixin { - Directory? imageFolderPath; - @override bool get wantKeepAlive => true; @@ -135,17 +135,6 @@ class _LineUpMediaPagesState extends ConsumerState void initState() { super.initState(); keepAlive = InAppWebViewKeepAlive(); - _loadDirectory(); - } - - Future _loadDirectory() async { - final strategyID = ref.read(strategyProvider).id; - final dir = await PlacedImageProvider.getImageFolder(strategyID); - if (mounted) { - setState(() { - imageFolderPath = dir; - }); - } } @override @@ -167,9 +156,6 @@ class _LineUpMediaPagesState extends ConsumerState @override Widget build(BuildContext context) { super.build(context); - if (imageFolderPath == null) { - return const Center(child: CircularProgressIndicator()); - } Widget content; if (widget.images.isEmpty && widget.youtubeLink.isEmpty) { @@ -203,23 +189,25 @@ class _LineUpMediaPagesState extends ConsumerState } final image = widget.images[imageIndex]; - final fullPath = path.join( - imageFolderPath!.path, image.id + image.fileExtension); - final file = File(fullPath); - - if (!file.existsSync()) { - return const Center( - child: Icon(LucideIcons.imageOff, color: Colors.white)); - } - - return InteractiveViewer( - minScale: 0.5, - maxScale: 4.0, - child: Image.file( - file, - fit: BoxFit.contain, - ), + final source = watchStrategyImageSource( + ref, + (id: image.id, fileExtension: image.fileExtension), ); + return switch (source.imageProvider) { + final imageProvider? => InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + child: Image( + key: ValueKey(image.id), + image: imageProvider, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + ), + null => const Center( + child: Icon(LucideIcons.imageOff, color: Colors.white), + ), + }; }, ), ), diff --git a/lib/widgets/line_up_widget.dart b/lib/widgets/line_up_widget.dart index f82c1e5e..bbbc1f1f 100644 --- a/lib/widgets/line_up_widget.dart +++ b/lib/widgets/line_up_widget.dart @@ -5,12 +5,15 @@ import 'package:icarus/const/abilities.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/screen_zoom_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/ability_visibility_context_menu.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; const double _pinnedRingGap = 3; const double _pinnedRingStroke = 2; @@ -20,6 +23,7 @@ class LineUpOriginAgentWidget extends ConsumerWidget { Key? key, required this.origin, this.interactive = true, + this.onDragEnd, }) : super(key: key ?? ValueKey('lineup-agent-widget-${origin.id}')); final LineUpOrigin origin; @@ -28,6 +32,9 @@ class LineUpOriginAgentWidget extends ConsumerWidget { /// while this origin is pinned; the real one underneath keeps the hitbox. final bool interactive; + /// Moves the committed origin. Null leaves the marker fixed in place. + final ValueChanged? onDragEnd; + @override Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; @@ -45,23 +52,47 @@ class LineUpOriginAgentWidget extends ConsumerWidget { ), ); + Widget marker = _PinnedEnd( + isPinned: isPinned, + child: AgentWidget( + lineUpId: origin.id, + agent: AgentData.agents[origin.agent.type]!, + isAlly: origin.agent.isAlly, + weapon: origin.agent.weapon, + id: origin.agent.id, + isInteractive: interactive, + ), + ); + if (interactive && onDragEnd != null) { + marker = Draggable( + key: ValueKey('lineup-agent-drag-${origin.id}'), + data: origin.agent, + dragAnchorStrategy: + ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, + feedback: Opacity( + opacity: Settings.feedbackOpacity, + child: ZoomTransform( + child: AgentWidget( + agent: AgentData.agents[origin.agent.type]!, + isAlly: origin.agent.isAlly, + weapon: origin.agent.weapon, + id: '', + ), + ), + ), + childWhenDragging: const SizedBox.shrink(), + onDragEnd: onDragEnd, + child: marker, + ); + } + return Positioned( key: ValueKey('lineup-agent-${origin.id}'), left: agentScreen.dx, top: agentScreen.dy, child: IgnorePointer( ignoring: !interactive, - child: _PinnedEnd( - isPinned: isPinned, - child: AgentWidget( - lineUpId: origin.id, - agent: AgentData.agents[origin.agent.type]!, - isAlly: origin.agent.isAlly, - weapon: origin.agent.weapon, - id: origin.agent.id, - isInteractive: interactive, - ), - ), + child: marker, ), ); } @@ -72,6 +103,7 @@ class LineUpLandingAbilityWidget extends ConsumerWidget { Key? key, required this.landing, this.interactive = true, + this.onDragEnd, }) : super(key: key ?? ValueKey('lineup-ability-widget-${landing.id}')); final LineUpLanding landing; @@ -80,6 +112,9 @@ class LineUpLandingAbilityWidget extends ConsumerWidget { /// while this landing is pinned; the real one underneath keeps the hitbox. final bool interactive; + /// Moves the committed landing. Null leaves the marker fixed in place. + final ValueChanged? onDragEnd; + @override Widget build(BuildContext context, WidgetRef ref) { final coordinateSystem = CoordinateSystem.instance; @@ -115,26 +150,48 @@ class LineUpLandingAbilityWidget extends ConsumerWidget { (state) => state.placement?.pinnedLandingId == landing.id, ), ); - Widget abilityChild = ability.data.abilityData!.createWidget( - id: null, - isAlly: ability.isAlly, - mapScale: mapScale, - landingId: landing.id, - rotation: displayRotation, - length: ability.length, - armLengthsMeters: ability.armLengthsMeters, - visualState: ability.visualState, - watchMouse: interactive, - contextMenuItems: contextMenuItems, - ); - if (shouldRotate) { - abilityChild = Transform.rotate( + Widget buildAbility({required bool isFeedback}) { + final child = ability.data.abilityData!.createWidget( + id: null, + isAlly: ability.isAlly, + mapScale: mapScale, + landingId: landing.id, + rotation: displayRotation, + length: ability.length, + armLengthsMeters: ability.armLengthsMeters, + visualState: ability.visualState, + watchMouse: interactive && !isFeedback, + contextMenuItems: isFeedback ? null : contextMenuItems, + ); + if (!shouldRotate) return child; + return Transform.rotate( angle: displayRotation, alignment: Alignment.topLeft, origin: ability.data.abilityData! .getAnchorPoint(mapScale: mapScale, abilitySize: abilitySize) .scale(coordinateSystem.scaleFactor, coordinateSystem.scaleFactor), - child: abilityChild, + child: child, + ); + } + + Widget marker = _PinnedEnd( + isPinned: isPinned, + child: buildAbility(isFeedback: false), + ); + if (interactive && onDragEnd != null) { + marker = Draggable( + key: ValueKey('lineup-ability-drag-${landing.id}'), + data: ability, + dragAnchorStrategy: + ref.read(screenZoomProvider.notifier).zoomDragAnchorStrategy, + feedback: Opacity( + opacity: Settings.feedbackOpacity, + child: ZoomTransform(child: buildAbility(isFeedback: true)), + ), + childWhenDragging: const SizedBox.shrink(), + onDragEnd: onDragEnd, + // The ability art has transparent gaps; the whole box takes the drag. + child: ColoredBox(color: Colors.transparent, child: marker), ); } @@ -144,10 +201,7 @@ class LineUpLandingAbilityWidget extends ConsumerWidget { top: abilityScreen.dy, child: IgnorePointer( ignoring: !interactive, - child: _PinnedEnd( - isPinned: isPinned, - child: abilityChild, - ), + child: marker, ), ); } diff --git a/lib/widgets/lineup_editors_notice.dart b/lib/widgets/lineup_editors_notice.dart new file mode 100644 index 00000000..6b9cb9c0 --- /dev/null +++ b/lib/widgets/lineup_editors_notice.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Who else is editing the lineups a dialog shows, so the user knows before +/// changing them that edits made at the same time will conflict. Nothing +/// when no one is. +/// +/// [itemId] is any lineup, origin or landing the dialog shows: they are all +/// in one lineup group, and the group is what teammates edit. +class LineUpEditorsNotice extends ConsumerWidget { + const LineUpEditorsNotice({ + super.key, + required this.itemId, + this.lineups = false, + }); + + final String itemId; + + /// The dialog shows several lineups rather than one. + final bool lineups; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final editors = ref.watch(lineupGroupEditorsProvider(itemId)); + if (editors.isEmpty) return const SizedBox.shrink(); + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: presenceColorFor(editors.first.uid), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + lineUpEditorsText(editors, lineups: lineups), + // The label role: 12px/600. + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } +} + +/// "Sam is editing this lineup right now." and its plural forms. +String lineUpEditorsText(List editors, {bool lineups = false}) { + final what = lineups ? 'these lineups' : 'this lineup'; + final who = switch (editors) { + [final one] => '${one.name} is', + [final a, final b] => '${a.name} and ${b.name} are', + [final a, ...final rest] => '${a.name} and ${rest.length} others are', + [] => '', + }; + return '$who editing $what right now.'; +} diff --git a/lib/widgets/map_selector.dart b/lib/widgets/map_selector.dart index 09f70bfb..fe798e34 100644 --- a/lib/widgets/map_selector.dart +++ b/lib/widgets/map_selector.dart @@ -251,13 +251,17 @@ class _SideToggle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isAttack = ref.watch(mapProvider.select((state) => state.isAttack)); - final strategyId = ref.watch(strategyProvider.select((state) => state.id)); + final strategyId = + ref.watch(strategyProvider.select((state) => state.strategyId)); final box = Hive.box(HiveBoxNames.strategiesBox); return ValueListenableBuilder( valueListenable: box.listenable(keys: [strategyId]), builder: (context, Box b, _) { - final pages = b.get(strategyId)?.pages ?? const []; + // Reads the local copy; a cloud strategy has none here, so it never + // shows the mixed dot. + final pages = + (strategyId == null ? null : b.get(strategyId))?.pages ?? const []; final mixed = pages.any((page) => page.isAttack != pages.first.isAttack); final nextSide = isAttack ? 'Defense' : 'Attack'; diff --git a/lib/widgets/map_theme_settings_section.dart b/lib/widgets/map_theme_settings_section.dart index 29a41d93..49fb4f60 100644 --- a/lib/widgets/map_theme_settings_section.dart +++ b/lib/widgets/map_theme_settings_section.dart @@ -43,7 +43,7 @@ class _ThemeProfilesList extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final profilesState = ref.watch(mapThemeProfilesProvider); final strategyTheme = ref.watch(strategyThemeProvider); - final hasActiveStrategy = ref.watch(strategyProvider).stratName != null; + final hasActiveStrategy = ref.watch(strategyProvider).strategyName != null; final overridePalette = hasActiveStrategy ? strategyTheme.overridePalette : null; diff --git a/lib/widgets/mouse_navigation.dart b/lib/widgets/mouse_navigation.dart index 26d3e142..b038e829 100644 --- a/lib/widgets/mouse_navigation.dart +++ b/lib/widgets/mouse_navigation.dart @@ -4,6 +4,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/app_navigator.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/routes.dart'; @@ -11,8 +12,11 @@ import 'package:icarus/providers/agent_filter_provider.dart'; import 'package:icarus/providers/delete_menu_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy_view.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -76,16 +80,22 @@ class _FolderLocation extends _NavLocation { } class _StrategyLocation extends _NavLocation { - const _StrategyLocation(this.strategyId); + const _StrategyLocation(this.strategyId, this.source); final String strategyId; + /// Where the strategy was opened from. A local and a cloud strategy can + /// share an id, so history reopens it through the same store. + final StrategySource source; + @override bool operator ==(Object other) => - other is _StrategyLocation && other.strategyId == strategyId; + other is _StrategyLocation && + other.strategyId == strategyId && + other.source == source; @override - int get hashCode => Object.hash(_StrategyLocation, strategyId); + int get hashCode => Object.hash(_StrategyLocation, strategyId, source); } /// Makes the mouse side buttons (back/forward) walk the user's browsing @@ -111,6 +121,7 @@ class _MouseNavigationState extends ConsumerState { ProviderSubscription? _folderSub; ProviderSubscription? _strategySub; + ProviderSubscription? _sharedStrategySub; @override void initState() { @@ -123,19 +134,35 @@ class _MouseNavigationState extends ConsumerState { strategyProvider, (_, __) => _recordLocationChange(), ); + // A redeemed share link opens its strategy the way any other navigation + // does here: guarded when an editor is open, recorded in history. + _sharedStrategySub = ref.listenManual( + sharedStrategyToOpenProvider, + (_, strategyPublicId) { + if (strategyPublicId == null) return; + ref.read(sharedStrategyToOpenProvider.notifier).state = null; + unawaited(_navigateTo( + _StrategyLocation(strategyPublicId, StrategySource.cloud), + )); + }, + ); } @override void dispose() { _folderSub?.close(); _strategySub?.close(); + _sharedStrategySub?.close(); super.dispose(); } _NavLocation _deriveLocation() { final strategy = ref.read(strategyProvider); if (strategy.stratName != null) { - return _StrategyLocation(strategy.id); + return _StrategyLocation( + strategy.id, + strategy.source ?? StrategySource.local, + ); } return _FolderLocation(ref.read(folderProvider)); } @@ -246,10 +273,13 @@ class _MouseNavigationState extends ConsumerState { case _FolderLocation(:final folderId): return folderId == null || ref.read(folderProvider.notifier).findFolderByID(folderId) != null; - case _StrategyLocation(:final strategyId): - return Hive.box(HiveBoxNames.strategiesBox) - .get(strategyId) != - null; + case _StrategyLocation(:final strategyId, source: StrategySource.local): + return ref.read(platformPolicyProvider).allowsLocalLibrary && + Hive.box(HiveBoxNames.strategiesBox) + .get(strategyId) != + null; + case _StrategyLocation(source: StrategySource.cloud): + return ref.read(isCloudWorkspaceAvailableProvider); } } @@ -282,7 +312,7 @@ class _MouseNavigationState extends ConsumerState { ref.read(folderProvider.notifier).updateID(folderId); return true; - case _StrategyLocation(:final strategyId): + case _StrategyLocation(:final strategyId, :final source): if (strategyOpen) { // Already in the strategy view; swap strategies in place, exactly // like the quick switcher does. @@ -292,14 +322,29 @@ class _MouseNavigationState extends ConsumerState { source: 'MouseNavigation.navigateToStrategy', onContinue: () async { _resetStrategyUiState(); - await ref - .read(strategyProvider.notifier) - .loadFromHive(strategyId); + final strategies = ref.read(strategyProvider.notifier); + switch (source) { + case StrategySource.local: + await strategies.loadFromHive(strategyId); + case StrategySource.cloud: + await strategies.openCloudStrategy(strategyId); + } }, ); return switched && mounted; } + if (source == StrategySource.cloud) { + unawaited( + navigator.push( + StrategyView.route( + initialStrategyId: strategyId, + initialStrategySource: StrategySource.cloud, + ), + ), + ); + return true; + } final strategy = Hive.box(HiveBoxNames.strategiesBox) .get(strategyId); if (strategy == null) return false; @@ -308,6 +353,7 @@ class _MouseNavigationState extends ConsumerState { StrategyView.route( initialStrategyId: strategy.id, initialStrategyName: strategy.name, + initialStrategySource: StrategySource.local, initialMapValue: strategy.mapData, initialIsAttack: strategy.pages.isEmpty || strategy.pages.first.isAttack, diff --git a/lib/widgets/page_transition_overlay.dart b/lib/widgets/page_transition_overlay.dart index c65d52dd..e11095cb 100644 --- a/lib/widgets/page_transition_overlay.dart +++ b/lib/widgets/page_transition_overlay.dart @@ -743,7 +743,6 @@ class PlacedWidgetPreview { return ImageWidget( fileExtension: w.fileExtension, aspectRatio: w.aspectRatio, - link: w.link, scale: scale ?? w.scale, id: w.id, tagColorValue: w.tagColorValue, diff --git a/lib/widgets/pages_bar.dart b/lib/widgets/pages_bar.dart index d0808862..6c1fecdd 100644 --- a/lib/widgets/pages_bar.dart +++ b/lib/widgets/pages_bar.dart @@ -3,13 +3,22 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/sort_index_order.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/collab/trashed_pages_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart' + hide PageTransitionState; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/transition_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/custom_text_field.dart'; -import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/delete_page_dialog.dart'; +import 'package:icarus/widgets/dialogs/recently_deleted_dialog.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; const double _pagesBarCornerRadius = 12; const double _pagesBarFooterHeight = 48; @@ -19,6 +28,18 @@ const double _pagesBarControlSize = const double _pagesBarInnerButtonRadius = _pagesBarCornerRadius - _pagesBarControlInset; +class PageListItemViewModel { + const PageListItemViewModel({ + required this.id, + required this.name, + this.isAutoNamed = false, + }); + + final String id; + final String name; + final bool isAutoNamed; +} + class PagesBar extends ConsumerStatefulWidget { const PagesBar({super.key}); @@ -193,15 +214,19 @@ class _PagesBarState extends ConsumerState { } Future _addPage() async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canAddPage) return; await ref.read(strategyProvider.notifier).addPage(); } Future _selectPage(String id) async { - if (id == ref.read(strategyProvider.notifier).activePageID) return; + if (id == ref.read(strategyPageSessionProvider).activePageId) return; await ref.read(strategyProvider.notifier).setActivePageAnimated(id); } - Future _renamePage(StrategyData strat, StrategyPage page) async { + Future _renamePage(PageListItemViewModel page) async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canRenamePage) return; final controller = TextEditingController(text: page.name); final newName = await showShadDialog( context: context, @@ -227,156 +252,362 @@ class _PagesBarState extends ConsumerState { ), ), ); + controller.dispose(); if (newName == null || newName.isEmpty || - (newName == page.name && page.isAutoNamed == false)) { + (newName == page.name && !page.isAutoNamed)) { return; } - - final box = Hive.box(HiveBoxNames.strategiesBox); - final updatedPages = [ - for (final p in strat.pages) - if (p.id == page.id) - p.copyWith(name: newName, isAutoNamed: false) - else - p, - ]; - final updated = - strat.copyWith(pages: updatedPages, lastEdited: DateTime.now()); - await box.put(updated.id, updated); - controller.dispose(); + await ref.read(strategyProvider.notifier).renamePage(page.id, newName); } - Future _deletePage(StrategyData strat, StrategyPage page) async { - if (strat.pages.length == 1) return; // cannot delete last + Future _deletePage(PageListItemViewModel page, int pageCount) async { + final caps = ref.read(currentStrategyCapabilitiesProvider); + if (!caps.canDeletePage || pageCount <= 1) return; + // The page belongs to the strategy open now, whatever opens meanwhile. + final strategyId = ref.read(strategyProvider).strategyId; + + final confirm = await confirmDeletePage( + context, + ref, + pageId: page.id, + pageName: page.name, + ); - final confirm = await ConfirmAlertDialog.show( - context: context, - title: "Delete '${page.name}'?", - content: - "Are you sure you want to delete this page? This action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", - isDestructive: true, + if (confirm != true || !_showing(strategyId)) return; + final wasActive = + ref.read(strategyPageSessionProvider).activePageId == page.id; + final isCloud = ref.read(strategyProvider).source == StrategySource.cloud; + final deleted = + await ref.read(strategyProvider.notifier).deletePage(page.id); + // Only a cloud page goes to the trash, so only it can be undone, and only + // from the strategy it was deleted in. + if (!deleted || !isCloud || !_showing(strategyId)) return; + _dismissUndo(); + late final ToastificationItem toast; + toast = Settings.showToast( + message: "Deleted '${page.name}'", + backgroundColor: Settings.tacticalVioletTheme.secondary, + autoCloseDuration: const Duration(seconds: 6), + actionLabel: 'Undo', + onActionPressed: () { + // Taken once: a second tap finds nothing to undo. + if (!identical(_undoToast, toast)) return; + _dismissUndo(); + _undoDelete(page, strategyId: strategyId, wasActive: wasActive); + }, ); + _undoToast = toast; + } - if (confirm != true) return; + /// The Undo offer for the last delete here. It goes when this bar does + /// (the strategy closed); the page stays in Recently deleted. + ToastificationItem? _undoToast; - final box = Hive.box(HiveBoxNames.strategiesBox); - final remaining = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)) - ..removeWhere((p) => p.id == page.id); - final reindexed = - StrategyProvider.reindexPagesAfterStructuralChange(remaining); - final activeId = ref.read(strategyProvider.notifier).activePageID; - final newActive = (activeId == page.id) ? reindexed.first.id : activeId; + void _dismissUndo() { + final toast = _undoToast; + _undoToast = null; + if (toast != null) toastification.dismiss(toast); + } - final updated = strat.copyWith( - pages: reindexed, - lastEdited: DateTime.now(), - ); - await box.put(updated.id, updated); - if (newActive != activeId) { - if (newActive != null) - await ref - .read(strategyProvider.notifier) - .setActivePageAnimated(newActive); + @override + void dispose() { + // The toast list rebuilds on dismiss, which a tree being torn down + // cannot take: dismiss after this frame. + final toast = _undoToast; + _undoToast = null; + if (toast != null) { + WidgetsBinding.instance + .addPostFrameCallback((_) => toastification.dismiss(toast)); } + super.dispose(); + } + + /// Whether this bar is still up, showing the strategy [strategyId]. Checked + /// after every wait before anything is read. + bool _showing(String? strategyId) => + mounted && ref.read(strategyProvider).strategyId == strategyId; + + /// Takes [page] back out of the trash; the delete was just made here, in + /// the strategy [strategyId]. + Future _undoDelete( + PageListItemViewModel page, { + required String? strategyId, + required bool wasActive, + }) async { + bool stillHere() => _showing(strategyId); + if (!stillHere()) return; + final session = ref.read(strategyPageSessionProvider.notifier); + final strategyName = ref.read(strategyProvider).strategyName; + final outcome = await session.restorePageFromTrash(page.id); + if (outcome != DeletedPageRestore.restored) { + // Said even after leaving, so no one thinks Undo worked. + Settings.showToast( + message: undoFailedMessage( + page.name, + outcome, + strategyName: stillHere() ? null : (strategyName ?? ''), + ), + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + if (!wasActive || !stillHere()) return; + // Back to the page the delete moved you off, once the list has it. + await ref.read(remoteEditorSnapshotProvider.notifier).refresh(); + if (!stillHere()) return; + if (!_lists(page.id)) { + // The server has it back; the list shows it when it next loads. + Settings.showToast( + message: "Restored '${page.name}'.", + backgroundColor: Settings.tacticalVioletTheme.secondary, + ); + return; + } + try { + await session.setActivePageAnimated( + page.id, + direction: PageTransitionDirection.backward, + ); + } catch (_) { + if (!stillHere()) return; + Settings.showToast( + message: _lists(page.id) + ? "Restored '${page.name}', but could not open it. It is in the " + 'pages list.' + : "Restored '${page.name}', but could not open it.", + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + } + } + + /// Whether the last snapshot lists the page [pageId]. + bool _lists(String pageId) => + ref + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.pages + .any((page) => page.publicId == pageId) ?? + false; + + Future _openRecentlyDeleted() async { + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return; + await RecentlyDeletedDialog.show(context, strategyId); } @override Widget build(BuildContext context) { - final strategyId = ref.watch(strategyProvider).id; - final activePageIdFromState = - ref.watch(strategyProvider.select((state) => state.activePageId)); + final activePageId = ref.watch( + strategyPageSessionProvider.select((state) => state.activePageId), + ); + final caps = ref.watch(currentStrategyCapabilitiesProvider); final persistedExpandedHeight = ref.watch( appPreferencesProvider.select((prefs) => prefs.pagesBarExpandedHeight), ); final persistedWidth = ref.watch( appPreferencesProvider.select((prefs) => prefs.pagesBarWidth), ); - final box = Hive.box(HiveBoxNames.strategiesBox); - - return ValueListenableBuilder( - valueListenable: box.listenable(keys: [strategyId]), - builder: (context, Box b, _) { - final strat = _strategy(b, strategyId); - if (strat == null) return const SizedBox(); - - final pages = [...strat.pages] - ..sort((a, b) => a.sortIndex.compareTo(b.sortIndex)); - final activePageId = - activePageIdFromState ?? (pages.isNotEmpty ? pages.first.id : null); - final activeName = pages - .firstWhere( - (p) => p.id == activePageId, - orElse: () => pages.first, - ) - .name; - - final width = _effectiveWidth(persistedWidth); - - return Stack( - clipBehavior: Clip.none, - children: [ - Container( - key: _barKey, - decoration: BoxDecoration( - color: Settings.tacticalVioletTheme.card, - borderRadius: BorderRadius.circular(_pagesBarCornerRadius), - border: Border.all( - color: Settings.tacticalVioletTheme.border, - width: 2, - ), - ), - width: width, - padding: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.only(right: _widthResizeHandleWidth), - child: _expanded - ? _ExpandedPanel( - pages: pages, - activePageId: activePageId, - height: - _effectiveExpandedHeight(persistedExpandedHeight), - panelKey: _expandedPanelKey, - onSelect: _selectPage, - onRename: (p) => _renamePage(strat, p), - onDelete: (p) => _deletePage(strat, p), - onAdd: _addPage, - onCollapse: _collapsePanel, - onResizeStart: _startHeightResize, - onResizeUpdate: _updateHeightResize, - onResizeEnd: _endHeightResize, - isResizeActive: _isHeightResizing, - ) - : _CollapsedPill( - activeName: activeName, - onAdd: _addPage, - onToggle: () => setState(() => _expanded = true), - ), - ), - ), - Positioned( - top: 0, - right: 2, - bottom: 0, - child: _ResizeHandle( - width: _widthResizeHandleWidth, - axis: Axis.horizontal, - onResizeStart: _startWidthResize, - onResizeUpdate: _updateWidthResize, - onResizeEnd: _endWidthResize, - isActive: _isWidthResizing, - ), + final isCloud = ref.watch( + strategyProvider.select((value) => value.source), + ) == + StrategySource.cloud; + + if (!isCloud) { + final strategyId = ref.watch(strategyProvider).strategyId; + if (strategyId == null) { + return const SizedBox.shrink(); + } + final box = Hive.box(HiveBoxNames.strategiesBox); + return ValueListenableBuilder( + valueListenable: box.listenable(keys: [strategyId]), + builder: (context, Box b, _) { + final data = _buildLocalData(b, strategyId, activePageId); + if (data == null || data.pages.isEmpty) { + return const SizedBox.shrink(); + } + return _buildPageBar( + data: data, + caps: caps, + hasTrash: false, + persistedExpandedHeight: persistedExpandedHeight, + persistedWidth: persistedWidth, + ); + }, + ); + } + + final data = _buildCloudData(activePageId); + if (data == null || data.pages.isEmpty) { + return const SizedBox.shrink(); + } + return _buildPageBar( + data: data, + caps: caps, + hasTrash: true, + persistedExpandedHeight: persistedExpandedHeight, + persistedWidth: persistedWidth, + ); + } + + /// [hasTrash]: deleted pages go to a trash they can be restored from (a + /// cloud strategy's server keeps one). + Widget _buildPageBar({ + required _PageBarData data, + required StrategyCapabilities caps, + required bool hasTrash, + required double persistedExpandedHeight, + required double persistedWidth, + }) { + final width = _effectiveWidth(persistedWidth); + + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + key: _barKey, + decoration: BoxDecoration( + color: Settings.tacticalVioletTheme.card, + borderRadius: BorderRadius.circular(_pagesBarCornerRadius), + border: Border.all( + color: Settings.tacticalVioletTheme.border, + width: 2, ), - ], - ); - }, + ), + width: width, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.only(right: _widthResizeHandleWidth), + child: _expanded + ? _ExpandedPanel( + pages: data.pages, + activePageId: data.activePageId, + canAddPage: caps.canAddPage, + canRenamePage: caps.canRenamePage, + canDeletePage: caps.canDeletePage, + canReorderPages: caps.canReorderPages, + height: _effectiveExpandedHeight(persistedExpandedHeight), + panelKey: _expandedPanelKey, + onSelect: _selectPage, + onRename: caps.canRenamePage ? _renamePage : null, + onDelete: caps.canDeletePage ? _deletePage : null, + onAdd: _addPage, + // Only when there is something in it: an empty trash + // is clutter. + onOpenRecentlyDeleted: hasTrash && + caps.canDeletePage && + (ref.watch(hasTrashedPagesProvider).valueOrNull ?? + false) + ? _openRecentlyDeleted + : null, + onCollapse: _collapsePanel, + onReorder: caps.canReorderPages + ? (oldIndex, newIndex) => ref + .read(strategyProvider.notifier) + .reorderPage(oldIndex, newIndex) + : null, + onResizeStart: _startHeightResize, + onResizeUpdate: _updateHeightResize, + onResizeEnd: _endHeightResize, + isResizeActive: _isHeightResizing, + ) + : _CollapsedPill( + activeName: data.activeName, + onAdd: caps.canAddPage ? _addPage : null, + onToggle: () => setState(() => _expanded = true), + ), + ), + ), + Positioned( + top: 0, + right: 2, + bottom: 0, + child: _ResizeHandle( + width: _widthResizeHandleWidth, + axis: Axis.horizontal, + onResizeStart: _startWidthResize, + onResizeUpdate: _updateWidthResize, + onResizeEnd: _endWidthResize, + isActive: _isWidthResizing, + ), + ), + ], + ); + } + + _PageBarData? _buildLocalData( + Box box, + String strategyId, + String? activePageId, + ) { + final strategy = _strategy(box, strategyId); + if (strategy == null || strategy.pages.isEmpty) { + return null; + } + final pages = [...strategy.pages] + ..sortBySortIndex((item) => item.sortIndex); + final items = pages + .map( + (page) => PageListItemViewModel( + id: page.id, + name: page.name, + isAutoNamed: page.isAutoNamed == true, + ), + ) + .toList(growable: false); + return _pageBarData(items, activePageId); + } + + _PageBarData? _buildCloudData(String? activePageId) { + final snapshot = ref.watch(remoteEditorSnapshotProvider).valueOrNull; + if (snapshot == null || snapshot.pages.isEmpty) { + return null; + } + final pages = [...snapshot.pages] + ..sortBySortIndex((item) => item.sortIndex); + final items = pages + .map( + (page) => PageListItemViewModel( + id: page.publicId, + name: page.name, + isAutoNamed: page.isAutoNamed == true, + ), + ) + .toList(growable: false); + return _pageBarData(items, activePageId); + } + + _PageBarData _pageBarData( + List pages, + String? activePageId, + ) { + final resolvedActiveId = activePageId ?? pages.first.id; + final activeName = pages + .firstWhere( + (page) => page.id == resolvedActiveId, + orElse: () => pages.first, + ) + .name; + return _PageBarData( + pages: pages, + activePageId: resolvedActiveId, + activeName: activeName, ); } } +class _PageBarData { + const _PageBarData({ + required this.pages, + required this.activePageId, + required this.activeName, + }); + + final List pages; + final String activePageId; + final String activeName; +} + /* -------- Collapsed pill -------- */ class _CollapsedPill extends StatelessWidget { const _CollapsedPill({ @@ -386,7 +617,7 @@ class _CollapsedPill extends StatelessWidget { }); final String activeName; - final VoidCallback onAdd; + final VoidCallback? onAdd; final VoidCallback onToggle; @override @@ -440,32 +671,47 @@ class _ExpandedPanel extends ConsumerWidget { const _ExpandedPanel({ required this.pages, required this.activePageId, + required this.canAddPage, + required this.canRenamePage, + required this.canDeletePage, + required this.canReorderPages, required this.height, required this.panelKey, required this.onSelect, required this.onRename, required this.onDelete, required this.onAdd, + required this.onOpenRecentlyDeleted, required this.onCollapse, required this.onResizeStart, required this.onResizeUpdate, required this.onResizeEnd, required this.isResizeActive, + this.onReorder, }); - final List pages; - final String? activePageId; + final List pages; + final String activePageId; + final bool canAddPage; + final bool canRenamePage; + final bool canDeletePage; + final bool canReorderPages; final double height; final GlobalKey panelKey; final ValueChanged onSelect; - final ValueChanged onRename; - final ValueChanged onDelete; + final ValueChanged? onRename; + final Future Function(PageListItemViewModel page, int pageCount)? + onDelete; final VoidCallback onAdd; + + /// Opens Recently deleted; null where there is no trash to open. + final VoidCallback? onOpenRecentlyDeleted; final VoidCallback onCollapse; final VoidCallback onResizeStart; final ValueChanged onResizeUpdate; final Future Function() onResizeEnd; final bool isResizeActive; + final void Function(int oldIndex, int newIndex)? onReorder; static const double _rowHeight = 40; // each page tile height static const double _verticalSpacing = 10; // separator height @@ -497,9 +743,7 @@ class _ExpandedPanel extends ConsumerWidget { ? 56.0 : (pages.length * _rowHeight) + ((pages.length - 1) * _verticalSpacing); final needsScroll = contentListHeight > availableListHeight + 0.5; - final activeIndex = activePageId == null - ? -1 - : pages.indexWhere((p) => p.id == activePageId); + final activeIndex = pages.indexWhere((p) => p.id == activePageId); final transitionState = ref.watch(transitionProvider); int? backwardIndex; @@ -536,9 +780,7 @@ class _ExpandedPanel extends ConsumerWidget { if (oldIndex < newIndex) { newIndex -= 1; } - ref - .read(strategyProvider.notifier) - .reorderPage(oldIndex, newIndex); + (onReorder ?? (_, __) {})(oldIndex, newIndex); }, padding: const EdgeInsets.fromLTRB(8, 0, 0, 8), shrinkWrap: false, @@ -571,24 +813,33 @@ class _ExpandedPanel extends ConsumerWidget { } } + final row = Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _PageRow( + page: p, + active: p.id == activePageId, + showBackwardIndicator: showBackwardIndicator, + showForwardIndicator: showForwardIndicator, + transitionProgress: + _rowTransitionProgress(transitionState, p.id), + onSelect: onSelect, + onRename: onRename, + onDelete: onDelete, + pageCount: pages.length, + canRename: canRenamePage, + disableDelete: !canDeletePage || pages.length == 1, + ), + ); + if (!canReorderPages) { + return KeyedSubtree( + key: ValueKey(p.id), + child: row, + ); + } return ReorderableDragStartListener( key: ValueKey(p.id), index: i, - child: Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _PageRow( - page: p, - active: p.id == activePageId, - showBackwardIndicator: showBackwardIndicator, - showForwardIndicator: showForwardIndicator, - transitionProgress: - _rowTransitionProgress(transitionState, p.id), - onSelect: onSelect, - onRename: onRename, - onDelete: onDelete, - disableDelete: pages.length == 1, - ), - ), + child: row, ); }, ), @@ -603,12 +854,33 @@ class _ExpandedPanel extends ConsumerWidget { const SizedBox(width: _pagesBarControlInset), _SquareIconButton( icon: LucideIcons.plus, - onTap: onAdd, + onTap: canAddPage ? onAdd : null, tooltip: "Add page", color: Settings.tacticalVioletTheme.primary, shortcutLabel: 'C', ), const Spacer(), + if (onOpenRecentlyDeleted != null) + ShadTooltip( + builder: (context) => const Text('Recently deleted'), + child: ShadIconButton.ghost( + width: _pagesBarControlSize, + height: _pagesBarControlSize, + padding: EdgeInsets.zero, + foregroundColor: + Settings.tacticalVioletTheme.mutedForeground, + hoverForegroundColor: Colors.white, + onPressed: onOpenRecentlyDeleted, + icon: const Icon(LucideIcons.archiveRestore, size: 18), + decoration: ShadDecoration( + border: ShadBorder( + radius: BorderRadius.circular( + _pagesBarInnerButtonRadius, + ), + ), + ), + ), + ), ShadIconButton.ghost( width: _pagesBarControlSize, height: _pagesBarControlSize, @@ -638,8 +910,8 @@ class _ExpandedPanel extends ConsumerWidget { state.phase != PageTransitionPhase.animating) { return null; } - if (state.sourcePageId == pageId) return 1 - state.progress; - if (state.targetPageId == pageId) return state.progress; + if (state.sourcePageId == pageId) return (1 - state.progress).toDouble(); + if (state.targetPageId == pageId) return state.progress.toDouble(); return null; } } @@ -755,17 +1027,22 @@ class _PageRow extends StatefulWidget { required this.onSelect, required this.onRename, required this.onDelete, + required this.pageCount, + required this.canRename, required this.disableDelete, }); - final StrategyPage page; + final PageListItemViewModel page; final bool active; final bool showBackwardIndicator; final bool showForwardIndicator; final double? transitionProgress; final ValueChanged onSelect; - final ValueChanged onRename; - final ValueChanged onDelete; + final ValueChanged? onRename; + final Future Function(PageListItemViewModel page, int pageCount)? + onDelete; + final int pageCount; + final bool canRename; final bool disableDelete; static const double _rowHeight = 40; @@ -878,10 +1155,14 @@ class _PageRowState extends State<_PageRow> { child: ShadIconButton.ghost( width: 24, hoverBackgroundColor: Colors.transparent, - foregroundColor: Colors.white, + foregroundColor: widget.canRename + ? Colors.white + : Colors.white24, icon: const Icon(LucideIcons.pen, size: 18, color: Colors.white), - onPressed: () => widget.onRename(widget.page), + onPressed: widget.canRename + ? () => widget.onRename?.call(widget.page) + : null, ), ), const SizedBox(width: 2), @@ -901,7 +1182,8 @@ class _PageRowState extends State<_PageRow> { ), onPressed: widget.disableDelete ? null - : () => widget.onDelete(widget.page), + : () => widget.onDelete + ?.call(widget.page, widget.pageCount), ), ), ], @@ -963,7 +1245,7 @@ class _SquareIconButton extends StatelessWidget { }); final IconData icon; - final VoidCallback onTap; + final VoidCallback? onTap; final String tooltip; final Color color; final String? shortcutLabel; @@ -1019,3 +1301,25 @@ class _SquareIconButton extends StatelessWidget { ); } } + +/// What Undo can say about [pageName] when it did not confirm a restore. +/// [strategyName] is null while the user is still in the page's strategy, +/// and names it (or is empty when unknown) once they have left. A failure +/// may be a reply that never arrived, so it says what it does not know. +@visibleForTesting +String undoFailedMessage( + String pageName, + DeletedPageRestore outcome, { + required String? strategyName, +}) { + if (outcome == DeletedPageRestore.gone) { + return "'$pageName' can no longer be restored."; + } + final (where, trash) = switch (strategyName) { + null => ('', 'Recently deleted'), + '' => ('', "its strategy's Recently deleted"), + final name => (' in $name', "that strategy's Recently deleted"), + }; + return "Could not confirm that '$pageName' was restored$where. Once " + "you're connected, check the pages list or $trash."; +} diff --git a/lib/widgets/platform_feature_toast.dart b/lib/widgets/platform_feature_toast.dart new file mode 100644 index 00000000..d6194681 --- /dev/null +++ b/lib/widgets/platform_feature_toast.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/settings.dart'; + +/// Returns true when [feature] runs here. Otherwise tells the user, in one +/// short toast, that it is desktop-only or coming to the web beta, and +/// returns false so the caller can stop. +bool ensureFeatureAvailable(WidgetRef ref, PlatformFeature feature) { + final message = + ref.read(platformPolicyProvider).unavailableMessage(feature); + if (message == null) return true; + // Not an error: the neutral surface, not the destructive one. + Settings.showToast( + message: message, + backgroundColor: Settings.tacticalVioletTheme.secondary, + ); + return false; +} diff --git a/lib/widgets/role_badge.dart b/lib/widgets/role_badge.dart new file mode 100644 index 00000000..b2368d4a --- /dev/null +++ b/lib/widgets/role_badge.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// What the cloud says about a strategy's place in the library: yours, shared +/// with edit access, or shared read-only. +enum CloudBadgeKind { owned, editor, viewer } + +CloudBadgeKind? cloudBadgeKindForRole(String? role) { + switch (role) { + case 'owner': + return CloudBadgeKind.owned; + case 'editor': + return CloudBadgeKind.editor; + case 'viewer': + return CloudBadgeKind.viewer; + default: + // Unknown non-null roles fall back to the viewer badge. This mirrors + // StrategyCapabilities.fromCloudRole (strategy_capabilities_provider), + // which grants viewer capabilities to any role that is not exactly + // 'owner' or 'editor' — so the badge matches what the user can actually + // do, and shared items are never mistaken for owned ones. + return role == null ? null : CloudBadgeKind.viewer; + } +} + +/// The pill drawn over a strategy thumbnail to say where it lives or how it +/// was shared. Owned cloud strategies are the ordinary case in a signed-in +/// library, so they carry no badge; only the exceptions get one. +class StrategyBadge extends StatelessWidget { + const StrategyBadge({ + super.key, + required this.icon, + required this.label, + required this.tooltip, + }); + + final IconData icon; + final String label; + final String tooltip; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: theme.background.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: theme.border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: theme.foreground), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ], + ), + ), + ); + } +} + +/// "On this device": the strategy exists only in the local library. +class DeviceOnlyBadge extends StatelessWidget { + const DeviceOnlyBadge({super.key}); + + @override + Widget build(BuildContext context) { + return const StrategyBadge( + icon: LucideIcons.monitor, + label: 'On this device', + tooltip: 'Saved only on this computer', + ); + } +} + +/// The share role of a strategy someone else owns. Null for owned strategies. +class CloudRoleBadge extends StatelessWidget { + const CloudRoleBadge({super.key, required this.kind}); + + final CloudBadgeKind kind; + + @override + Widget build(BuildContext context) { + switch (kind) { + case CloudBadgeKind.owned: + return const SizedBox.shrink(); + case CloudBadgeKind.editor: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · Can edit', + tooltip: 'Shared with you. You can edit it.', + ); + case CloudBadgeKind.viewer: + return const StrategyBadge( + icon: LucideIcons.users, + label: 'Shared · View only', + tooltip: 'Shared with you. Ask the owner for edit access.', + ); + } + } +} diff --git a/lib/widgets/settings_tab.dart b/lib/widgets/settings_tab.dart index 46f7e198..8872590e 100644 --- a/lib/widgets/settings_tab.dart +++ b/lib/widgets/settings_tab.dart @@ -2,18 +2,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/widgets/client_upgrade_button.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/marker_sizes_sync.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/services/analytics_service.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/account_avatar.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; import 'package:icarus/widgets/map_theme_settings_section.dart'; import 'package:icarus/widgets/settings_scope_card.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -26,6 +34,7 @@ enum _SettingsMode { enum _SettingsSection { strategyObjects, strategyMapTheme, + globalAccount, globalDefaults, globalSaving, globalMapVisibility, @@ -104,10 +113,13 @@ class _SettingsTabState extends ConsumerState { child: SingleChildScrollView( controller: _scrollController, child: switch (_mode) { - _SettingsMode.strategy => _StrategySettingsSections( - key: const ValueKey('strategy-settings'), - sectionKeys: _sectionKeys, - strategySettings: strategySettings, + _SettingsMode.strategy => StrategyEditBoundary( + disabledOpacity: 0.55, + child: _StrategySettingsSections( + key: const ValueKey('strategy-settings'), + sectionKeys: _sectionKeys, + strategySettings: strategySettings, + ), ), _SettingsMode.global => _GlobalSettingsSections( key: const ValueKey('global-settings'), @@ -161,6 +173,7 @@ class _SettingsTabState extends ConsumerState { case _SettingsSection.strategyObjects: case _SettingsSection.strategyMapTheme: return _SettingsMode.strategy; + case _SettingsSection.globalAccount: case _SettingsSection.globalDefaults: case _SettingsSection.globalSaving: case _SettingsSection.globalMapVisibility: @@ -299,6 +312,12 @@ class _GlobalSettingsSections extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + AccountSettingsSection( + key: sectionKeys[_SettingsSection.globalAccount], + ), + const SizedBox(height: 20), + const _SettingsItemDivider(), + const SizedBox(height: 20), SettingsScopeCard( key: sectionKeys[_SettingsSection.globalDefaults], title: "New strategy defaults", @@ -1012,6 +1031,203 @@ class _ShortcutEmptySearch extends StatelessWidget { } } +class AccountSettingsSection extends ConsumerWidget { + const AccountSettingsSection({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final authState = ref.watch(authProvider); + + return SettingsScopeCard( + title: 'Account', + description: authState.isAuthenticated + ? 'Your cloud identity and sync connection.' + : 'Sign in to sync strategies to the cloud and share them.', + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: authState.isAuthenticated + ? _SignedInAccountRow( + key: const ValueKey('account-signed-in'), + authState: authState, + ) + : Padding( + key: const ValueKey('account-signed-out'), + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + Icon( + LucideIcons.cloudOff, + size: 18, + color: Settings.tacticalVioletTheme.mutedForeground, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Not signed in', + style: TextStyle( + color: Settings.tacticalVioletTheme.mutedForeground, + ), + ), + ), + ShadButton( + size: ShadButtonSize.sm, + onPressed: authState.isLoading + ? null + : () { + showDialog( + context: context, + builder: (_) => const AuthDialog(), + ); + }, + child: const Text('Log In'), + ), + ], + ), + ), + ), + ); + } +} + +class _SignedInAccountRow extends ConsumerWidget { + const _SignedInAccountRow({super.key, required this.authState}); + + final AppAuthState authState; + + @override + Widget build(BuildContext context, WidgetRef ref) { + const theme = Settings.tacticalVioletTheme; + final email = authState.user?.email; + final avatarUrl = authState.avatarUrl; + // The server refuses this build. Reconnecting can't help; reloading or + // updating can, so this row says what the sync button says. + final needsNewerIcarus = ref.watch(clientUpgradeRequiredProvider); + + final (String statusLabel, IconData statusIcon, Color statusColor) = + needsNewerIcarus + ? ( + 'Cloud sync needs a newer Icarus', + LucideIcons.circleAlert, + theme.destructive, + ) + : switch (authState.convexAuthStatus) { + ConvexAuthStatus.ready => ( + 'Cloud sync active', + LucideIcons.cloudCheck, + theme.mutedForeground, + ), + ConvexAuthStatus.configuring => ( + 'Connecting to cloud…', + LucideIcons.refreshCw, + theme.mutedForeground, + ), + ConvexAuthStatus.incident => ( + 'Cloud connection needs attention', + LucideIcons.circleAlert, + theme.destructive, + ), + ConvexAuthStatus.signedOut => ( + 'Cloud sync inactive', + LucideIcons.cloudOff, + theme.mutedForeground, + ), + }; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + AccountAvatar( + radius: 16, + backgroundColor: theme.muted, + avatarUrl: avatarUrl, + fallback: Text( + authState.displayName.characters.first.toUpperCase(), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authState.displayName, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + if (email != null && email != authState.displayName) + Text( + email, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.mutedForeground, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(statusIcon, size: 13, color: statusColor), + const SizedBox(width: 5), + Flexible( + child: Text( + statusLabel, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: statusColor, + fontSize: 12, + ), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + if (needsNewerIcarus) ...[ + ClientUpgradeNotice( + buttonSize: ShadButtonSize.sm, + builder: (context, message, action) => + action ?? const SizedBox.shrink(), + ), + const SizedBox(width: 8), + ] else if (authState.convexAuthStatus == + ConvexAuthStatus.incident) ...[ + ShadButton( + size: ShadButtonSize.sm, + onPressed: () { + ref + .read(authProvider.notifier) + .reinitializeConvexAuth(source: 'settings_account'); + }, + child: const Text('Reconnect'), + ), + const SizedBox(width: 8), + ], + ShadButton.outline( + key: const ValueKey('settings-sign-out'), + size: ShadButtonSize.sm, + onPressed: authState.isLoading + ? null + : () async { + await ref.read(guardedSignOutRequestProvider)(context); + }, + child: const Text('Sign Out'), + ), + ], + ), + ); + } +} + class _SettingsNavigationRail extends StatelessWidget { const _SettingsNavigationRail({ required this.selectedSection, @@ -1055,6 +1271,12 @@ class _SettingsNavigationRail extends StatelessWidget { const SizedBox(height: 20), const _SettingsNavHeader(label: "App-wide"), const SizedBox(height: 4), + _SettingsNavItem( + icon: LucideIcons.user, + label: "Account", + isSelected: selectedSection == _SettingsSection.globalAccount, + onTap: () => onSectionSelected(_SettingsSection.globalAccount), + ), _SettingsNavItem( icon: LucideIcons.wandSparkles, label: "Defaults", @@ -1422,13 +1644,17 @@ class _PageMarkerSizesSyncBannerState @override Widget build(BuildContext context) { final stratState = ref.watch(strategyProvider); + final activePageId = ref.watch( + strategyPageSessionProvider.select((state) => state.activePageId)); final liveSettings = ref.watch(strategySettingsProvider); - final strategy = - Hive.box(HiveBoxNames.strategiesBox).get(stratState.id); - final showCta = stratState.stratName != null && + final strategyId = stratState.strategyId; + final strategy = strategyId == null + ? null + : Hive.box(HiveBoxNames.strategiesBox).get(strategyId); + final showCta = stratState.strategyName != null && markerSizesDifferAcrossPages( strategy: strategy, - activePageId: stratState.activePageId, + activePageId: activePageId, liveSettings: liveSettings, ); diff --git a/lib/widgets/sidebar_widgets/custom_shape_tools.dart b/lib/widgets/sidebar_widgets/custom_shape_tools.dart index 70cac0c5..0d2f4a94 100644 --- a/lib/widgets/sidebar_widgets/custom_shape_tools.dart +++ b/lib/widgets/sidebar_widgets/custom_shape_tools.dart @@ -179,7 +179,7 @@ class _CustomShapeToolsState extends ConsumerState { min: 5, max: 80, leading: Icon(LucideIcons.droplet, - color: Settings.tacticalVioletTheme.mutedForeground), + size: 16, color: Settings.tacticalVioletTheme.mutedForeground), // label: 'O', hintText: 'Enter Opacity', suffix: '%', diff --git a/lib/widgets/sidebar_widgets/tool_grid.dart b/lib/widgets/sidebar_widgets/tool_grid.dart index 32479859..0687ab00 100644 --- a/lib/widgets/sidebar_widgets/tool_grid.dart +++ b/lib/widgets/sidebar_widgets/tool_grid.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:icarus/const/coordinate_system.dart'; @@ -14,6 +13,7 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/placement_center_provider.dart'; import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/dialogs/upload_image_dialog.dart'; import 'package:icarus/widgets/draggable_widgets/zoom_transform.dart'; @@ -23,6 +23,8 @@ import 'package:icarus/widgets/sidebar_widgets/custom_shape_tools.dart'; import 'package:icarus/widgets/sidebar_widgets/drawing_tools.dart'; import 'package:icarus/widgets/sidebar_widgets/text_tools.dart'; import 'package:icarus/widgets/sidebar_widgets/vision_cone_tools.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:uuid/uuid.dart'; @@ -192,6 +194,12 @@ class ToolGrid extends ConsumerWidget { builder: (context) => const Text("Add Image"), child: ShadIconButton.secondary( onPressed: () async { + if (!ensureFeatureAvailable( + ref, + PlatformFeature.addImages, + )) { + return; + } ref .read(interactionStateProvider.notifier) .update(InteractionState.navigation); @@ -214,19 +222,10 @@ class ToolGrid extends ConsumerWidget { return; } - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the Windows version.', - backgroundColor: - Settings.tacticalVioletTheme.destructive, - ); - return; - } - final aspectRatio = await ref .read(placedImageProvider.notifier) .getImageAspectRatio(imageBytes); + final strategyState = ref.read(strategyProvider); final placementCenter = ref.read(placementCenterProvider); final imageHeight = _defaultImageSpawnWidth / aspectRatio; final centeredTopLeft = @@ -238,6 +237,8 @@ class ToolGrid extends ConsumerWidget { ref.read(placedImageProvider.notifier).addImage( imageBytes: imageBytes, + strategyId: strategyState.strategyId, + strategySource: strategyState.source, fileExtension: fileExtension, aspectRatio: aspectRatio, position: centeredTopLeft, @@ -251,12 +252,10 @@ class ToolGrid extends ConsumerWidget { tooltip: "Add Lineup", shortcutLabel: shortcutLabel(IcarusShortcutAction.addLineup), onPressed: () async { - if (kIsWeb) { - Settings.showToast( - message: - 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); + if (!ensureFeatureAvailable( + ref, + PlatformFeature.addLineups, + )) { return; } diff --git a/lib/widgets/strategy_edit_boundary.dart b/lib/widgets/strategy_edit_boundary.dart new file mode 100644 index 00000000..38730e99 --- /dev/null +++ b/lib/widgets/strategy_edit_boundary.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; + +/// Disables controls that mutate the open strategy when it is view-only. +class StrategyEditBoundary extends ConsumerWidget { + const StrategyEditBoundary({ + super.key, + required this.child, + this.disabledOpacity = 1, + }); + + final Widget child; + final double disabledOpacity; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final canEdit = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); + + return AbsorbPointer( + absorbing: !canEdit, + child: ExcludeFocus( + excluding: !canEdit, + child: Opacity( + opacity: canEdit ? 1 : disabledOpacity, + child: child, + ), + ), + ); + } +} diff --git a/lib/widgets/strategy_presence.dart b/lib/widgets/strategy_presence.dart new file mode 100644 index 00000000..11f357f4 --- /dev/null +++ b/lib/widgets/strategy_presence.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/screen_zoom_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/widgets/account_avatar.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// The same hue for a person on every screen. String.hashCode differs between +/// web and native, so hash by hand, keeping every step small enough to be +/// exact in JavaScript numbers. +Color presenceColorFor(String uid) { + var hash = 0; + for (final unit in uid.codeUnits) { + hash = (hash * 31 + unit) % 1000003; + } + return Settings.presenceColors[hash % Settings.presenceColors.length]; +} + +/// Everyone else with this strategy open, as a row of avatars in the window +/// strip. Shows nothing when you are alone or the strategy is local. +class StrategyPresenceAvatars extends ConsumerWidget { + const StrategyPresenceAvatars({super.key}); + + static const int _maxShown = 4; + static const double _size = 24; + static const double _overlap = 6; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Cursors move many times a second; only rebuild when the people change. + ref.watch(strategyPresenceProvider.select(_peopleSignature)); + final people = ref.read(strategyPresenceProvider).people; + if (people.isEmpty) return const SizedBox.shrink(); + + final shown = people.take(_maxShown).toList(); + final hidden = people.skip(_maxShown).toList(); + final slots = shown.length + (hidden.isEmpty ? 0 : 1); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: SizedBox( + key: const ValueKey('strategy-presence-avatars'), + height: _size, + width: _size + (slots - 1) * (_size - _overlap), + child: Stack( + children: [ + for (final (index, peer) in shown.indexed) + Positioned( + left: index * (_size - _overlap), + child: _PresenceAvatar(peer: peer, size: _size), + ), + if (hidden.isNotEmpty) + Positioned( + left: shown.length * (_size - _overlap), + child: _OverflowAvatar(people: hidden, size: _size), + ), + ], + ), + ), + ), + ); + } + + static String _peopleSignature(PresenceRoomState state) => [ + for (final peer in state.people) + '${peer.uid}\u0000${peer.name}\u0000${peer.avatarUrl}\u0000${peer.role}', + ].join('\u0001'); +} + +const _belowStrip = ShadAnchor( + offset: Offset(0, 6), + childAlignment: Alignment.topCenter, + overlayAlignment: Alignment.bottomCenter, +); + +String _describe(PresencePeer peer) => + peer.role == 'viewer' ? '${peer.name} (viewing)' : peer.name; + +class _PresenceAvatar extends StatelessWidget { + const _PresenceAvatar({required this.peer, required this.size}); + + final PresencePeer peer; + final double size; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + final initial = peer.name.isEmpty ? '?' : peer.name.characters.first; + return ShadTooltip( + builder: (context) => Text(_describe(peer)), + // The strip is the top of the window; there is no room above it. + anchor: _belowStrip, + child: _Ring( + color: presenceColorFor(peer.uid), + size: size, + child: AccountAvatar( + radius: size / 2 - 3, + backgroundColor: theme.secondary, + avatarUrl: peer.avatarUrl, + fallback: Text( + initial.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ), + ), + ); + } +} + +class _OverflowAvatar extends StatelessWidget { + const _OverflowAvatar({required this.people, required this.size}); + + final List people; + final double size; + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return ShadTooltip( + builder: (context) => Text(people.map(_describe).join('\n')), + anchor: _belowStrip, + child: _Ring( + color: theme.border, + size: size, + child: CircleAvatar( + radius: size / 2 - 3, + backgroundColor: theme.secondary, + child: Text( + '+${people.length}', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: theme.foreground, + ), + ), + ), + ), + ); + } +} + +/// A person's color as a thin ring, with a gap in the strip's color so +/// overlapping avatars stay distinct. +class _Ring extends StatelessWidget { + const _Ring({required this.color, required this.size, required this.child}); + + final Color color; + final double size; + final Widget child; + + @override + Widget build(BuildContext context) { + // Rings sit inside a ShadTooltip, which only opens for children that + // report hover through the Shad theme; a plain Container doesn't. + return ShadGestureDetector( + child: Container( + width: size, + height: size, + padding: const EdgeInsets.all(1.5), + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + child: Container( + padding: const EdgeInsets.all(1.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Settings.tacticalVioletTheme.card, + ), + child: child, + ), + ), + ); + } +} + +/// Reports this user's pointer over the map to the presence room, in +/// attack-side world coordinates so it lands in the same spot for everyone. +/// +/// Sits over the map viewport with no child and a translucent hit test, so it +/// sees every hover and drag while everything under it still gets them. The +/// map can also move under a still pointer (scroll zoom, a page or side +/// change, a resize), so it reports again from the last pointer position +/// whenever any of those change. +class PresenceCursorReporter extends ConsumerStatefulWidget { + const PresenceCursorReporter({ + super.key, + required this.transformationController, + required this.coordinateSystem, + required this.isAttack, + }); + + final TransformationController transformationController; + final CoordinateSystem coordinateSystem; + final bool isAttack; + + @override + ConsumerState createState() => + _PresenceCursorReporterState(); +} + +class _PresenceCursorReporterState + extends ConsumerState { + /// Where the pointer is in the viewport, while it is over the map. + Offset? _pointer; + + @override + void initState() { + super.initState(); + widget.transformationController.addListener(_reportAgain); + } + + @override + void didUpdateWidget(PresenceCursorReporter oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.transformationController != widget.transformationController) { + oldWidget.transformationController.removeListener(_reportAgain); + widget.transformationController.addListener(_reportAgain); + } + if (oldWidget.isAttack != widget.isAttack || + oldWidget.coordinateSystem.effectiveSize != + widget.coordinateSystem.effectiveSize) { + _reportAgain(); + } + } + + @override + void dispose() { + widget.transformationController.removeListener(_reportAgain); + super.dispose(); + } + + bool _reportScheduled = false; + + /// Reports again once the frame settles. A wheel zoom sets the transform + /// in steps (scale, then the pan that keeps the pointer's spot fixed), and + /// reporting between them would fling the cursor across teammates' maps. + void _reportAgain() { + if (_pointer == null || _reportScheduled) return; + _reportScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _reportScheduled = false; + final pointer = _pointer; + if (mounted && pointer != null) _report(pointer); + }); + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _report(Offset viewportPosition) { + _pointer = viewportPosition; + final world = widget.transformationController.toScene(viewportPosition); + final coordinateSystem = widget.coordinateSystem; + final canonical = coordinateSystem.positionFromSide( + sidePosition: coordinateSystem.screenToCoordinate(world), + reflectionOffset: Offset.zero, + isAttack: widget.isAttack, + ); + ref + .read(strategyPresenceProvider.notifier) + .moveCursor(canonical.dx, canonical.dy); + } + + @override + Widget build(BuildContext context) { + ref.listen( + strategyPageSessionProvider.select((s) => s.activePageId), + (_, __) => _reportAgain(), + ); + return MouseRegion( + opaque: false, + hitTestBehavior: HitTestBehavior.translucent, + onExit: (_) { + _pointer = null; + ref.read(strategyPresenceProvider.notifier).hideCursor(); + }, + child: Listener( + behavior: HitTestBehavior.translucent, + onPointerHover: (event) => _report(event.localPosition), + onPointerMove: (event) => _report(event.localPosition), + ), + ); + } +} + +/// Other people's cursors on the page being viewed. Lives inside the zoomed +/// world, so it pans with the map; each cursor counter-scales to stay the +/// same size on screen. +class RemoteCursorsLayer extends ConsumerWidget { + const RemoteCursorsLayer({ + super.key, + required this.coordinateSystem, + required this.isAttack, + }); + + final CoordinateSystem coordinateSystem; + final bool isAttack; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pageId = ref.watch( + strategyPageSessionProvider.select((s) => s.activePageId), + ); + if (pageId == null) return const SizedBox.shrink(); + final peers = ref.watch( + strategyPresenceProvider.select((s) => s.cursorsOn(pageId)), + ); + if (peers.isEmpty) return const SizedBox.shrink(); + final zoom = ref.watch(screenZoomProvider); + + return IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final peer in peers) _positioned(peer, zoom), + ], + ), + ); + } + + Widget _positioned(PresencePeer peer, double zoom) { + final cursor = peer.cursor!; + final screen = coordinateSystem.coordinateToScreen( + coordinateSystem.positionForSide( + canonicalPosition: Offset(cursor.x, cursor.y), + reflectionOffset: Offset.zero, + isAttack: isAttack, + ), + ); + // Near the right or bottom of the world the name tag would run off the + // map, so it flips to the other side of the arrow. + final world = coordinateSystem.effectiveSize; + final tagOnLeft = world.width - screen.dx < RemoteCursor.maxTagWidth / zoom; + final tagAbove = world.height - screen.dy < 48 / zoom; + // Updates arrive about 20 times a second; gliding between them reads as + // continuous motion instead of hops. + return AnimatedPositioned( + key: ValueKey(peer.sid), + duration: const Duration(milliseconds: 90), + curve: Curves.linear, + left: screen.dx, + top: screen.dy, + child: Transform.scale( + scale: 1 / zoom, + alignment: Alignment.topLeft, + child: RemoteCursor( + name: peer.name, + color: presenceColorFor(peer.uid), + tagOnLeft: tagOnLeft, + tagAbove: tagAbove, + ), + ), + ); + } +} + +/// An arrow with the person's name beside it. The arrow's tip is the +/// widget's top-left corner. +class RemoteCursor extends StatelessWidget { + const RemoteCursor({ + super.key, + required this.name, + required this.color, + this.tagOnLeft = false, + this.tagAbove = false, + }); + + static const Size arrowSize = Size(14, 18); + static const double maxTagWidth = 160; + + final String name; + final Color color; + final bool tagOnLeft; + final bool tagAbove; + + @override + Widget build(BuildContext context) { + final tag = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: maxTagWidth), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + boxShadow: const [Settings.cardForegroundBackdrop], + ), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Settings.presenceTagInk, + height: 1.2, + ), + ), + ), + ); + return SizedBox.fromSize( + size: arrowSize, + child: Stack( + clipBehavior: Clip.none, + children: [ + CustomPaint(size: arrowSize, painter: _ArrowPainter(color)), + Positioned( + left: tagOnLeft ? null : 10, + right: tagOnLeft ? arrowSize.width + 2 : null, + top: tagAbove ? null : 14, + bottom: tagAbove ? arrowSize.height + 2 : null, + child: tag, + ), + ], + ), + ); + } +} + +class _ArrowPainter extends CustomPainter { + const _ArrowPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width; + final h = size.height; + final path = Path() + ..moveTo(0, 0) + ..lineTo(0, h * 0.86) + ..lineTo(w * 0.3, h * 0.62) + ..lineTo(w * 0.52, h) + ..lineTo(w * 0.68, h * 0.93) + ..lineTo(w * 0.47, h * 0.56) + ..lineTo(w, h * 0.56) + ..close(); + canvas.drawPath( + path.shift(const Offset(0, 1)), + Paint() + ..color = Settings.cardForegroundBackdrop.color + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.5), + ); + canvas.drawPath(path, Paint()..color = color); + canvas.drawPath( + path, + Paint() + ..color = Settings.presenceTagInk + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..strokeJoin = StrokeJoin.round, + ); + } + + @override + bool shouldRepaint(_ArrowPainter oldDelegate) => oldDelegate.color != color; +} diff --git a/lib/widgets/strategy_quick_switcher.dart b/lib/widgets/strategy_quick_switcher.dart index de0b3cdb..b8a4be9d 100644 --- a/lib/widgets/strategy_quick_switcher.dart +++ b/lib/widgets/strategy_quick_switcher.dart @@ -4,13 +4,18 @@ import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/shortcut_info.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/agent_filter_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; import 'package:icarus/widgets/overflow_tooltip_text.dart'; import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; @@ -77,7 +82,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { Future _switchStrategy(String strategyId) async { if (_isSwitching || _isEditingName) return; final currentStrategy = ref.read(strategyProvider); - if (currentStrategy.id == strategyId) return; + if (currentStrategy.strategyId == strategyId) return; _closePortal(); setState(() => _isSwitching = true); @@ -111,8 +116,11 @@ class _StrategyQuickSwitcherState extends ConsumerState { void _startEditingName() { final currentStrategy = ref.read(strategyProvider); - final currentName = currentStrategy.stratName; + final currentName = currentStrategy.strategyName; if (_isSwitching || _isEditingName || currentName == null) return; + if (!ref.read(currentStrategyCapabilitiesProvider).canRenameStrategy) { + return; + } _closePortal(); _originalName = currentName; @@ -178,7 +186,7 @@ class _StrategyQuickSwitcherState extends ConsumerState { try { await ref .read(strategyProvider.notifier) - .renameStrategy(ref.read(strategyProvider).id, nextName); + .renameStrategy(ref.read(strategyProvider).strategyId!, nextName); if (!mounted) return; _originalName = null; setState(() { @@ -186,10 +194,23 @@ class _StrategyQuickSwitcherState extends ConsumerState { _isRenaming = false; }); _nameFocusNode.unfocus(); - } catch (_) { - if (!mounted) rethrow; - setState(() => _isRenaming = false); - rethrow; + } catch (error, stackTrace) { + // The rename did not happen: put the name back, leave edit mode (so + // focus changes do not submit it again), and say so once. + AppErrorReporter.reportWarning( + 'Strategy rename failed', + source: 'strategy_quick_switcher.rename', + error: redactSyncDiagnosticText(error), + stackTrace: stackTrace, + ); + if (!mounted) return; + _cancelEditingName(); + Settings.showToast( + message: isTypedConvexForbiddenError(error) + ? "You can't rename this strategy." + : friendlyCloudSyncError('$error'), + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); } } @@ -255,9 +276,16 @@ class _StrategyQuickSwitcherState extends ConsumerState { @override Widget build(BuildContext context) { final currentStrategy = ref.watch(strategyProvider); - final currentStrategyId = currentStrategy.id; - final strategyName = currentStrategy.stratName ?? 'Untitled Strategy'; + final currentStrategyId = currentStrategy.strategyId; + if (currentStrategyId == null) { + return const SizedBox.shrink(); + } + final strategyName = currentStrategy.strategyName ?? 'Untitled Strategy'; + final canRename = currentStrategy.strategyName != null && + ref.watch(currentStrategyCapabilitiesProvider).canRenameStrategy; final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); + final allowsLocalLibrary = + ref.watch(platformPolicyProvider).allowsLocalLibrary; return Padding( padding: _displayMargin, @@ -266,10 +294,14 @@ class _StrategyQuickSwitcherState extends ConsumerState { child: ValueListenableBuilder>( valueListenable: strategiesBox.listenable(), builder: (context, box, _) { - final recents = _recentStrategies( - box: box, - currentStrategyId: currentStrategyId, - ); + // Recents come from the on-device library, which this platform + // may keep hidden. + final recents = allowsLocalLibrary + ? _recentStrategies( + box: box, + currentStrategyId: currentStrategyId, + ) + : const []; return OverlayPortal.overlayChildLayoutBuilder( controller: _controller, @@ -442,19 +474,19 @@ class _StrategyQuickSwitcherState extends ConsumerState { ) : ShadTooltip( builder: (context) => Text( - currentStrategy.stratName == null + currentStrategy.strategyName == null ? 'Load a strategy to rename it' - : 'Rename strategy', + : canRename + ? 'Rename strategy' + : "You can view this strategy but not rename it", ), child: Material( color: Colors.transparent, child: InkWell( - onTap: currentStrategy.stratName == null - ? null - : _startEditingName, - mouseCursor: currentStrategy.stratName == null - ? SystemMouseCursors.basic - : SystemMouseCursors.click, + onTap: canRename ? _startEditingName : null, + mouseCursor: canRename + ? SystemMouseCursors.click + : SystemMouseCursors.basic, hoverColor: Settings.tacticalVioletTheme.accent, child: Center( diff --git a/lib/widgets/strategy_save_icon_button.dart b/lib/widgets/strategy_save_icon_button.dart index 78c634eb..e94aad94 100644 --- a/lib/widgets/strategy_save_icon_button.dart +++ b/lib/widgets/strategy_save_icon_button.dart @@ -2,20 +2,61 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/auto_save_notifier.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/editor_toolbar.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:toastification/toastification.dart'; -/// Saves the open strategy immediately and tells the user what landed. +const saveFailedMessage = "Couldn't save this strategy. Try again."; + +/// Saves the open strategy immediately and tells the user where the work now +/// is: saved, saved on this device and waiting to sync, or not saved. The +/// answer is read from the library and the outboxes after the save, never +/// assumed from the press. Future saveStrategyNow(BuildContext context, WidgetRef ref) async { - await ref - .read(strategyProvider.notifier) - .forceSaveNow(ref.read(strategyProvider).id); + final strategyId = ref.read(strategyProvider).strategyId; + if (strategyId == null) return; + final pressedAt = DateTime.now(); + var threw = false; + try { + await ref.read(strategyProvider.notifier).forceSaveNow(strategyId); + } catch (error, stackTrace) { + threw = true; + AppErrorReporter.reportError( + saveFailedMessage, + error: error, + stackTrace: stackTrace, + source: 'saveStrategyNow', + // The toast below tells the user. + promptUser: false, + ); + } if (!context.mounted) return; + final (:message, :failed) = threw + ? (message: saveFailedMessage, failed: true) + : ref.read(strategyProvider).source == StrategySource.cloud + ? _cloudSaveOutcome(ref) + : _localSaveOutcome(ref, pressedAt); + + if (failed) { + Settings.showToast( + message: message, + backgroundColor: Settings.tacticalVioletTheme.destructive, + ); + return; + } + toastification.showCustom( context: context, autoCloseDuration: const Duration(seconds: 3), @@ -30,20 +71,61 @@ Future saveStrategyNow(BuildContext context, WidgetRef ref) async { border: Border.all(color: Settings.tacticalVioletTheme.border), ), child: Text( - 'Save Complete', - style: ShadTheme.of(context).textTheme.small.copyWith( - color: Settings.tacticalVioletTheme.foreground, - ), + message, + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Settings.tacticalVioletTheme.foreground), ), ); }, ); } -/// The save button of the open strategy. Shows a spinner while an auto-save +/// A local save landed only if it wrote the library after the press. A +/// strategy missing from the library is skipped without a write. +({String message, bool failed}) _localSaveOutcome( + WidgetRef ref, + DateTime pressedAt, +) { + final lastPersistedAt = ref.read(strategySaveStateProvider).lastPersistedAt; + final persisted = + lastPersistedAt != null && !lastPersistedAt.isBefore(pressedAt); + return persisted + ? (message: 'Saved', failed: false) + : (message: saveFailedMessage, failed: true); +} + +/// A cloud save is in the outbox unless a write to it failed; whether it has +/// reached the server is the sync status, worded as the sync button words it. +({String message, bool failed}) _cloudSaveOutcome(WidgetRef ref) { + final opQueue = ref.read(strategyOpQueueProvider); + final mediaQueue = ref.read(cloudMediaUploadQueueProvider); + if (opQueue.hasDurabilityFailure || mediaQueue.durabilityError != null) { + return (message: unverifiedCloudWorkMessage, failed: true); + } + final saveState = ref.read(strategySaveStateProvider); + final message = switch (ref.read(cloudSyncStatusProvider)) { + CloudSyncStatus.synced => 'Saved', + CloudSyncStatus.editing => 'Edit not synced yet', + CloudSyncStatus.syncing => saveState.hasPendingMediaSync + ? 'Saved on this device. Media still syncing.' + : 'Saved on this device. Syncing…', + CloudSyncStatus.offline => 'Offline, changes stay on this device', + CloudSyncStatus.attention => saveState.mediaSyncErrorCount > 0 + ? 'Saved on this device. Media sync needs retry.' + : 'Saved on this device. Sync needs attention.', + }; + return (message: message, failed: false); +} + +/// The save button of a local strategy. Shows a spinner while an auto-save /// runs and a check when it lands, then rests on the save glyph. class AutoSaveButton extends ConsumerStatefulWidget { - const AutoSaveButton({super.key, this.style = kEditorToolbarButtonStyle}); + const AutoSaveButton({ + super.key, + this.style = kEditorToolbarButtonStyle, + }); final EditorToolbarButtonStyle style; @@ -88,6 +170,11 @@ class _AutoSaveButtonState extends ConsumerState { @override Widget build(BuildContext context) { final ping = ref.watch(autoSaveProvider); + final canEditPages = ref.watch( + currentStrategyCapabilitiesProvider.select( + (capabilities) => capabilities.canEditPages, + ), + ); if (ping != _lastPing) { _lastPing = ping; @@ -98,27 +185,28 @@ class _AutoSaveButtonState extends ConsumerState { final Widget icon = switch (_phase) { _Phase.idle => const Icon(LucideIcons.save200, key: ValueKey('idle')), _Phase.loading => SizedBox( - key: const ValueKey('loading'), - width: size - 2, - height: size - 2, - child: CircularProgressIndicator( - strokeWidth: 1.8, - valueColor: AlwaysStoppedAnimation( - Settings.tacticalVioletTheme.mutedForeground, + key: const ValueKey('loading'), + width: size - 2, + height: size - 2, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation( + Settings.tacticalVioletTheme.mutedForeground, + ), ), ), - ), _Phase.success => const Icon( - LucideIcons.check200, - key: ValueKey('success'), - color: Settings.allyBGColor, - ), + LucideIcons.check200, + key: ValueKey('success'), + color: Settings.allyBGColor, + ), }; return EditorToolbarButton( key: const ValueKey('local-save-button'), style: widget.style, - tooltip: 'Save', + tooltip: canEditPages ? 'Save' : 'View only', + enabled: canEditPages, onPressed: () => saveStrategyNow(context, ref), icon: AnimatedSwitcher( duration: const Duration(milliseconds: 150), diff --git a/lib/widgets/strategy_tile/strategy_tile.dart b/lib/widgets/strategy_tile/strategy_tile.dart index 9f667f04..5321cba3 100644 --- a/lib/widgets/strategy_tile/strategy_tile.dart +++ b/lib/widgets/strategy_tile/strategy_tile.dart @@ -1,17 +1,25 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/library_context_menu_provider.dart'; import 'package:icarus/providers/pinned_items_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/strategy_view.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart'; import 'package:icarus/widgets/dialogs/strategy/rename_strategy_dialog.dart'; import 'package:icarus/widgets/drag_tilt_feedback.dart'; import 'package:icarus/widgets/drop_insertion_indicator.dart'; import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:icarus/widgets/role_badge.dart'; import 'package:icarus/widgets/strategy_tile/strategy_tile_sections.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/widgets/platform_feature_toast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double strategyTileGridSpacing = 20; @@ -25,14 +33,96 @@ const double strategyTileInnerRadius = strategyTileOuterRadius - strategyTileInnerPadding; class StrategyTile extends ConsumerStatefulWidget { - const StrategyTile({super.key, required this.strategyData}); + const StrategyTile.local({ + super.key, + required this.strategyData, + this.showDeviceBadge = false, + }) : cloudStrategy = null, + canRename = true, + canDuplicate = true, + canDelete = true, + canMove = true; - final StrategyData strategyData; + const StrategyTile.cloud({ + super.key, + required this.cloudStrategy, + required this.canRename, + required this.canDuplicate, + required this.canDelete, + required this.canMove, + }) : strategyData = null, + showDeviceBadge = false; + + final StrategyData? strategyData; + + /// Marks a local strategy as not being in the cloud. Only meaningful while + /// the cloud is reachable; signed out, every strategy is on this device. + final bool showDeviceBadge; + final CloudStrategyEntry? cloudStrategy; + final bool canRename; + final bool canDuplicate; + final bool canDelete; + final bool canMove; @override ConsumerState createState() => _StrategyTileState(); } +class StrategyTileActionsButton extends StatelessWidget { + const StrategyTileActionsButton({ + super.key, + required this.strategyName, + required this.onPressed, + }); + + final String strategyName; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final label = 'More actions for $strategyName'; + return Semantics( + label: label, + button: true, + onTap: onPressed, + excludeSemantics: true, + child: ShadIconButton.secondary( + width: 28, + height: 28, + onPressed: onPressed, + icon: const Icon(LucideIcons.ellipsisVertical), + ), + ); + } +} + +class StrategyTileMenuActionSemantics extends StatelessWidget { + const StrategyTileMenuActionSemantics({ + super.key, + required this.label, + required this.enabled, + required this.onPressed, + required this.child, + }); + + final String label; + final bool enabled; + final VoidCallback? onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return Semantics( + label: label, + button: true, + enabled: enabled, + onTap: onPressed, + excludeSemantics: true, + child: child, + ); + } +} + const double _ringWidth = 2; class _StrategyTileState extends ConsumerState { @@ -47,6 +137,26 @@ class _StrategyTileState extends ConsumerState { ShadContextMenuController(); final DragTiltController _dragTiltController = DragTiltController(); + bool get _isCloud => widget.cloudStrategy != null; + bool get _canShare => _isCloud && widget.cloudStrategy?.role == 'owner'; + String get _strategyId => + widget.strategyData?.id ?? widget.cloudStrategy!.strategy.id; + String get _strategyName => + widget.strategyData?.name ?? widget.cloudStrategy!.strategy.name; + MapValue? get _mapValue => + widget.strategyData?.mapData ?? widget.cloudStrategy?.strategy.mapData; + bool get _isAttack { + final strategy = widget.strategyData; + if (strategy != null) { + return strategy.pages.isEmpty ? true : strategy.pages.first.isAttack; + } + return widget.cloudStrategy?.attackLabel != 'Defend'; + } + + StrategyTileViewData get _viewData => widget.strategyData != null + ? StrategyTileViewData.fromStrategy(widget.strategyData!) + : StrategyTileViewData.fromCloudEntry(widget.cloudStrategy!); + @override void dispose() { _menuButtonController.dispose(); @@ -77,25 +187,25 @@ class _StrategyTileState extends ConsumerState { (_, __) => _closeMenus(), ); - final viewData = StrategyTileViewData(widget.strategyData); + final viewData = _viewData; final pinned = ref.watch(pinnedItemsProvider); - final id = widget.strategyData.id; + final id = _strategyId; final isPinned = pinned.containsKey(id); return DragTarget( onWillAcceptWithDetails: (details) { final item = details.data; return item is StrategyItem && - item.strategy.id != id && + item.strategyId != id && isPinned && - pinned.containsKey(item.strategy.id); + pinned.containsKey(item.strategyId); }, onMove: (details) { final item = details.data; final nextSide = item is StrategyItem && - item.strategy.id != id && + item.strategyId != id && isPinned && - pinned.containsKey(item.strategy.id) + pinned.containsKey(item.strategyId) ? resolveDropInsertionSide( context: context, globalOffset: details.offset, @@ -128,7 +238,7 @@ class _StrategyTileState extends ConsumerState { if (insertionSide == null) return; await ref.read(pinnedItemsProvider.notifier).movePin( - id: item.strategy.id, + id: item.strategyId, targetId: id, insertAfterTarget: insertionSide == DropInsertionSide.after, ); @@ -137,15 +247,18 @@ class _StrategyTileState extends ConsumerState { final isPinDropTarget = candidateData.any( (item) => item is StrategyItem && - item.strategy.id != id && + item.strategyId != id && isPinned && - pinned.containsKey(item.strategy.id), + pinned.containsKey(item.strategyId), ); return Padding( padding: const EdgeInsets.all(strategyTileGutterOutset), child: Draggable( - data: StrategyItem(widget.strategyData), + data: _isCloud + ? StrategyItem.cloud(_strategyId) + : StrategyItem.local(widget.strategyData!), + maxSimultaneousDrags: widget.canMove ? null : 0, dragAnchorStrategy: pointerDragAnchorStrategy, onDragUpdate: (details) => _dragTiltController.addDelta(details.delta.dx), @@ -206,6 +319,9 @@ class _StrategyTileState extends ConsumerState { child: StrategyTileThumbnail( assetPath: viewData.thumbnailAsset, borderRadius: strategyTileInnerRadius, + overlay: widget.showDeviceBadge + ? const DeviceOnlyBadge() + : viewData.sharedBadge, ), ), const SizedBox(height: 10), @@ -293,7 +409,7 @@ class _StrategyTileState extends ConsumerState { List _buildMenuItems() { final pinned = ref.watch(pinnedItemsProvider); - final id = widget.strategyData.id; + final id = _strategyId; final isPinned = pinned.containsKey(id); return [ ShadContextMenuItem( @@ -306,19 +422,25 @@ class _StrategyTileState extends ConsumerState { ), ShadContextMenuItem( leading: const Icon(LucideIcons.pencil), + enabled: widget.canRename, + onPressed: widget.canRename + ? () { + _closeMenus(); + _showRenameDialog(); + } + : null, child: const Text('Rename'), - onPressed: () { - _closeMenus(); - _showRenameDialog(); - }, ), ShadContextMenuItem( leading: const Icon(LucideIcons.copy), + enabled: widget.canDuplicate, + onPressed: widget.canDuplicate + ? () { + _closeMenus(); + _duplicateStrategy(); + } + : null, child: const Text('Duplicate'), - onPressed: () { - _closeMenus(); - _duplicateStrategy(); - }, ), ShadContextMenuItem( leading: const Icon(LucideIcons.upload), @@ -328,15 +450,27 @@ class _StrategyTileState extends ConsumerState { _exportStrategy(); }, ), + if (_canShare) + ShadContextMenuItem( + leading: const Icon(LucideIcons.link2), + child: const Text('Share'), + onPressed: () { + _closeMenus(); + _showShareDialog(); + }, + ), ShadContextMenuItem( leading: Icon(LucideIcons.trash2, color: Settings.tacticalVioletTheme.destructive), + enabled: widget.canDelete, + onPressed: widget.canDelete + ? () { + _closeMenus(); + _showDeleteDialog(); + } + : null, child: Text('Delete', style: TextStyle(color: Settings.tacticalVioletTheme.destructive)), - onPressed: () { - _closeMenus(); - _showDeleteDialog(); - }, ), ]; } @@ -353,10 +487,12 @@ class _StrategyTileState extends ConsumerState { await Navigator.push( context, StrategyView.route( - initialStrategyId: widget.strategyData.id, - initialStrategyName: widget.strategyData.name, - initialMapValue: widget.strategyData.mapData, - initialIsAttack: _initialIsAttack(widget.strategyData), + initialStrategyId: _strategyId, + initialStrategyName: _strategyName, + initialStrategySource: + _isCloud ? StrategySource.cloud : StrategySource.local, + initialMapValue: _mapValue, + initialIsAttack: _isAttack, ), ); } finally { @@ -366,39 +502,43 @@ class _StrategyTileState extends ConsumerState { } } - bool _initialIsAttack(StrategyData strategy) { - return strategy.pages.isEmpty ? true : strategy.pages.first.isAttack; - } - Future _duplicateStrategy() async { - await ref - .read(strategyProvider.notifier) - .duplicateStrategy(widget.strategyData.id); + await ref.read(strategyProvider.notifier).duplicateStrategy( + _strategyId, + source: _isCloud ? StrategySource.cloud : StrategySource.local, + ); } Future _exportStrategy() async { - if (kIsWeb) { - Settings.showToast( - message: 'This feature is only supported in the Windows version.', - backgroundColor: Settings.tacticalVioletTheme.destructive, - ); + if (!ensureFeatureAvailable(ref, PlatformFeature.exportFiles)) return; + + if (_isCloud) { + await runCloudStrategyExport(ref, _strategyId); return; } - await ref - .read(strategyProvider.notifier) - .loadFromHive(widget.strategyData.id); - await ref - .read(strategyProvider.notifier) - .exportFile(widget.strategyData.id); + await ref.read(strategyProvider.notifier).loadFromHive(_strategyId); + await StrategyImportExportService(ref).exportFile(_strategyId); } Future _showRenameDialog() async { await showShadDialog( context: context, builder: (_) => RenameStrategyDialog( - strategyId: widget.strategyData.id, - currentName: widget.strategyData.name, + strategyId: _strategyId, + currentName: _strategyName, + source: _isCloud ? StrategySource.cloud : StrategySource.local, + ), + ); + } + + Future _showShareDialog() async { + await showShadDialog( + context: context, + builder: (_) => ShareLinksDialog( + targetType: 'strategy', + targetPublicId: _strategyId, + title: _strategyName, ), ); } @@ -407,8 +547,9 @@ class _StrategyTileState extends ConsumerState { showDialog( context: context, builder: (_) => DeleteStrategyAlertDialog( - strategyID: widget.strategyData.id, - name: widget.strategyData.name, + strategyID: _strategyId, + name: _strategyName, + source: _isCloud ? StrategySource.cloud : StrategySource.local, ), ); } diff --git a/lib/widgets/strategy_tile/strategy_tile_sections.dart b/lib/widgets/strategy_tile/strategy_tile_sections.dart index 13f8deeb..d5dd121d 100644 --- a/lib/widgets/strategy_tile/strategy_tile_sections.dart +++ b/lib/widgets/strategy_tile/strategy_tile_sections.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; import 'package:icarus/const/agents.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/widgets/overflow_tooltip_text.dart'; +import 'package:icarus/widgets/role_badge.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; const double _agentIconSize = 27; @@ -17,17 +20,46 @@ const List _strategyTileRoleOrder = [ ]; class StrategyTileViewData { - StrategyTileViewData(this.strategy) - : name = strategy.name, - mapName = _mapName(strategy.mapData), - attackLabel = _attackLabel(strategy.pages), - attackColor = _attackColor(strategy.pages), - thumbnailAsset = - 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', - lastEditedLabel = _timeAgo(strategy.lastEdited), - agentTypes = _collectAgentTypes(strategy.pages); - - final StrategyData strategy; + const StrategyTileViewData({ + required this.name, + required this.mapName, + required this.attackLabel, + required this.attackColor, + required this.thumbnailAsset, + required this.lastEditedLabel, + required this.agentTypes, + this.cloudBadge, + }); + + factory StrategyTileViewData.fromStrategy(StrategyData strategy) { + return StrategyTileViewData( + name: strategy.name, + mapName: _mapName(strategy.mapData), + attackLabel: _attackLabel(strategy.pages), + attackColor: _attackColor(_attackLabel(strategy.pages)), + thumbnailAsset: + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', + lastEditedLabel: _timeAgo(strategy.lastEdited), + agentTypes: _collectAgentTypes(strategy.pages), + ); + } + + factory StrategyTileViewData.fromCloudEntry(CloudStrategyEntry entry) { + final strategy = entry.strategy; + final attackLabel = entry.attackLabel; + return StrategyTileViewData( + name: strategy.name, + mapName: _mapName(strategy.mapData), + attackLabel: attackLabel, + attackColor: _attackColor(attackLabel), + thumbnailAsset: + 'assets/maps/thumbnails/${Maps.mapNames[strategy.mapData]}_thumbnail.webp', + lastEditedLabel: _timeAgo(strategy.lastEdited), + agentTypes: const [], + cloudBadge: cloudBadgeKindForRole(entry.role), + ); + } + final String name; final String mapName; final String attackLabel; @@ -36,8 +68,19 @@ class StrategyTileViewData { final String lastEditedLabel; final List agentTypes; - static String _mapName(MapValue map) { - final raw = Maps.mapNames[map]; + /// Non-null only for cloud strategies. Owned strategies render no badge; + /// shared ones show their role over the thumbnail. + final CloudBadgeKind? cloudBadge; + + /// The pill drawn over the thumbnail, if this strategy needs one. + Widget? get sharedBadge { + final kind = cloudBadge; + if (kind == null || kind == CloudBadgeKind.owned) return null; + return CloudRoleBadge(kind: kind); + } + + static String _mapName(MapValue? map) { + final raw = map == null ? null : Maps.mapNames[map]; if (raw == null || raw.isEmpty) { return 'Unknown'; } @@ -54,15 +97,14 @@ class StrategyTileViewData { return first ? 'Attack' : 'Defend'; } - static Color _attackColor(List pages) { - final label = _attackLabel(pages); + static Color _attackColor(String label) { switch (label) { case 'Attack': - return Colors.redAccent; + return Settings.attackColor; case 'Defend': - return Colors.lightBlueAccent; + return Settings.defenderColor; default: - return Colors.orangeAccent; + return Settings.mixedStrategyColor; } } @@ -116,6 +158,7 @@ class StrategyTileThumbnail extends StatelessWidget { this.height, this.width, this.borderRadius = 16, + this.overlay, }); final String assetPath; @@ -123,6 +166,9 @@ class StrategyTileThumbnail extends StatelessWidget { final double? width; final double borderRadius; + /// Drawn over the bottom-left corner of the map, e.g. a sync badge. + final Widget? overlay; + @override Widget build(BuildContext context) { Widget image = Image.asset(assetPath, fit: BoxFit.cover); @@ -131,6 +177,15 @@ class StrategyTileThumbnail extends StatelessWidget { } else { image = SizedBox.expand(child: image); } + if (overlay != null) { + image = Stack( + fit: StackFit.passthrough, + children: [ + image, + Positioned(left: 8, bottom: 8, child: overlay!), + ], + ); + } return ClipRRect( borderRadius: BorderRadius.circular(borderRadius), @@ -171,12 +226,20 @@ class StrategyTileDetails extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 130), - child: OverflowTooltipText( - data.name, - style: const TextStyle(fontWeight: FontWeight.w500), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 130), + child: OverflowTooltipText( + data.name, + style: + const TextStyle(fontWeight: FontWeight.w500), + ), + ), + ), + ], ), const SizedBox(height: 5), Text(data.mapName), diff --git a/lib/widgets/strategy_view_skeleton.dart b/lib/widgets/strategy_view_skeleton.dart index 7940e003..e49ff807 100644 --- a/lib/widgets/strategy_view_skeleton.dart +++ b/lib/widgets/strategy_view_skeleton.dart @@ -189,7 +189,9 @@ class _SkeletonTopBar extends StatelessWidget { decoration: BoxDecoration( color: _tone(Settings.tacticalVioletTheme.card, 0.95), borderRadius: BorderRadius.circular(8), - border: Border.all(color: _tone(Settings.highlightColor, 0.82)), + border: Border.all( + color: _tone(Settings.highlightColor, 0.82), + ), ), child: Center( child: title == null || title.isEmpty @@ -200,9 +202,10 @@ class _SkeletonTopBar extends StatelessWidget { title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: ShadTheme.of( - context, - ).textTheme.small.copyWith(color: Colors.white70), + style: ShadTheme.of(context) + .textTheme + .small + .copyWith(color: Colors.white70), ), ), ), diff --git a/lib/widgets/strip_status_icons.dart b/lib/widgets/strip_status_icons.dart index 4cc5eae3..68c65ca4 100644 --- a/lib/widgets/strip_status_icons.dart +++ b/lib/widgets/strip_status_icons.dart @@ -61,29 +61,15 @@ class WhatsNewIcon extends StatelessWidget { /// Windows installs open the in-app updater; Store and web installs reopen /// the dialog the automatic check shows, so it is a second chance at the /// same thing, not a second design. -class UpdateAvailableIcon extends ConsumerWidget { +class UpdateAvailableIcon extends StatelessWidget { const UpdateAvailableIcon({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final desktopController = ref.watch(desktopUpdateControllerProvider); - if (desktopController != null) { - return ListenableBuilder( - listenable: desktopController, - builder: (context, _) { - if (!desktopController.needUpdate) return const SizedBox.shrink(); - return _icon( - () => DesktopUpdateDialog.show(context, desktopController), - ); - }, - ); - } - - final status = ref.watch(appUpdateStatusProvider).valueOrNull; - if (status == null || !status.isUpdateAvailable) { - return const SizedBox.shrink(); - } - return _icon(() => UpdateChecker.showUpdateDialog(context, status)); + Widget build(BuildContext context) { + return WaitingUpdateBuilder( + builder: (context, openUpdate, _) => + openUpdate == null ? const SizedBox.shrink() : _icon(openUpdate), + ); } Widget _icon(VoidCallback onPressed) { @@ -99,3 +85,43 @@ class UpdateAvailableIcon extends ConsumerWidget { ); } } + +/// Builds with what opens the waiting update, or null while none is known: +/// the in-app updater for a direct Windows install, otherwise the dialog the +/// automatic check shows. [isChecking] is true while the update check runs. +class WaitingUpdateBuilder extends ConsumerWidget { + const WaitingUpdateBuilder({super.key, required this.builder}); + + final Widget Function( + BuildContext context, + VoidCallback? openUpdate, + bool isChecking, + ) builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final check = ref.watch(appUpdateStatusProvider); + final desktopController = ref.watch(desktopUpdateControllerProvider); + if (desktopController != null) { + return ListenableBuilder( + listenable: desktopController, + builder: (context, _) => builder( + context, + desktopController.needUpdate + ? () => DesktopUpdateDialog.show(context, desktopController) + : null, + check.isLoading, + ), + ); + } + + final status = check.valueOrNull; + return builder( + context, + status != null && status.isUpdateAvailable + ? () => UpdateChecker.showUpdateDialog(context, status) + : null, + check.isLoading, + ); + } +} diff --git a/lib/widgets/web_beta_tag.dart b/lib/widgets/web_beta_tag.dart new file mode 100644 index 00000000..0833c3c9 --- /dev/null +++ b/lib/widgets/web_beta_tag.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/widgets/dialogs/web_beta_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// Marks the web build as the beta. Tapping it explains what that means. +/// Callers show it only when the platform policy says this is the web beta. +/// +/// It lives in the library strip only, beside the tabs where the demo tag +/// was: the one deliberate exception to DESIGN.md's strip contents. The +/// editor carries no labels, so it never appears there. +class WebBetaTag extends StatelessWidget { + const WebBetaTag({super.key}); + + @override + Widget build(BuildContext context) { + const theme = Settings.tacticalVioletTheme; + return Semantics( + label: 'About the Icarus web beta', + button: true, + excludeSemantics: true, + child: ShadButton.outline( + key: const ValueKey('web-beta-tag'), + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 8), + foregroundColor: theme.mutedForeground, + hoverForegroundColor: theme.foreground, + onPressed: () => showShadDialog( + context: context, + builder: (_) => const WebBetaDialog(), + ), + child: const Text( + 'Beta', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ); + } +} diff --git a/lib/widgets/youtube_view.dart b/lib/widgets/youtube_view.dart index f396f4f8..f6df8a13 100644 --- a/lib/widgets/youtube_view.dart +++ b/lib/widgets/youtube_view.dart @@ -45,39 +45,52 @@ class YoutubeView extends StatefulWidget { class _YoutubeViewState extends State with AutomaticKeepAliveClientMixin { + late final Future _webViewWarmupFuture; + @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _webViewWarmupFuture = warmUpWebViewEnvironment(); + } + @override Widget build(BuildContext context) { super.build(context); - if (!isWebViewInitialized) { - // showShadDialog( - // context: context, - // builder: (context) { - // return const WebViewDialog(); - // }, - // ); - return const WebViewDialog(); - } - return Stack( - children: [ - const Align( - alignment: Alignment.center, - child: CircularProgressIndicator(), - ), - Positioned.fill( - child: InAppWebView( - webViewEnvironment: webViewEnvironment, - initialSettings: - InAppWebViewSettings(allowBackgroundAudioPlaying: false), - initialUrlRequest: URLRequest( - url: WebUri( - "https://embed.icarus-strats.xyz/?v=${YoutubeHandler.extractYoutubeIdWithTimestamp(widget.youtubeLink)}")), - ), - ), - ], + return FutureBuilder( + future: _webViewWarmupFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done && + !isWebViewWarmupComplete) { + return const Center(child: CircularProgressIndicator()); + } + + if (!isWebViewInitialized) { + return const WebViewDialog(); + } + + return Stack( + children: [ + const Align( + alignment: Alignment.center, + child: CircularProgressIndicator(), + ), + Positioned.fill( + child: InAppWebView( + webViewEnvironment: webViewEnvironment, + initialSettings: + InAppWebViewSettings(allowBackgroundAudioPlaying: false), + initialUrlRequest: URLRequest( + url: WebUri( + "https://embed.icarus-strats.xyz/?v=${YoutubeHandler.extractYoutubeIdWithTimestamp(widget.youtubeLink)}")), + ), + ), + ], + ); + }, ); } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 65c9e31e..0bfda768 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include #include @@ -20,6 +21,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) desktop_updater_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopUpdaterPlugin"); desktop_updater_plugin_register_with_registrar(desktop_updater_registrar); + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2ff8d63b..102035fd 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop desktop_updater + gtk pasteboard screen_retriever_linux url_launcher_linux @@ -12,6 +13,8 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d68755e8..54def675 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import app_links import cryptography_flutter_plus import custom_mouse_cursor import desktop_drop @@ -12,12 +13,13 @@ import desktop_updater import file_picker import flutter_inappwebview_macos import pasteboard -import path_provider_foundation import screen_retriever_macos +import shared_preferences_foundation import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin")) CustomMouseCursorPlugin.register(with: registry.registrar(forPlugin: "CustomMouseCursorPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) @@ -25,8 +27,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9c24f583..14873df8 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - convex_flutter (0.0.1): + - FlutterMacOS - cryptography_flutter_plus (0.2.0): - FlutterMacOS - custom_mouse_cursor (0.0.1): @@ -16,6 +18,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - convex_flutter (from `Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos`) - cryptography_flutter_plus (from `Flutter/ephemeral/.symlinks/plugins/cryptography_flutter_plus/macos`) - custom_mouse_cursor (from `Flutter/ephemeral/.symlinks/plugins/custom_mouse_cursor/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) @@ -29,6 +32,8 @@ SPEC REPOS: - OrderedSet EXTERNAL SOURCES: + convex_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos cryptography_flutter_plus: :path: Flutter/ephemeral/.symlinks/plugins/cryptography_flutter_plus/macos custom_mouse_cursor: @@ -45,6 +50,7 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + convex_flutter: a9d12846e80c8a2238282775fa9eb9c906efcea4 cryptography_flutter_plus: 7c6e0d0f08664499a2c5402aa7cbcca07f2a7130 custom_mouse_cursor: 5228910e366264d13615a102b1900946df2ab181 flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index 852fa1a4..a0463869 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -4,5 +4,9 @@ com.apple.security.app-sandbox + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..fe1ce501 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2451 @@ +{ + "name": "icarus", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "icarus", + "dependencies": { + "convex": "1.45.0" + }, + "devDependencies": { + "@edge-runtime/vm": "^3.2.0", + "@types/bun": "latest", + "convex-test": "^0.0.41", + "vitest": "^1.6.1" + }, + "peerDependencies": { + "typescript": "^5" + } + }, + "node_modules/@edge-runtime/primitives": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-4.1.0.tgz", + "integrity": "sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=16" + } + }, + "node_modules/@edge-runtime/vm": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@edge-runtime/vm/-/vm-3.2.0.tgz", + "integrity": "sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "@edge-runtime/primitives": "4.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bun": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.11.tgz", + "integrity": "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.11" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bun-types": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.11.tgz", + "integrity": "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/convex": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.45.0.tgz", + "integrity": "sha512-AV3B56Ptu/14d76g3urBJ50dwpiZa6uJaLT84OWox5aCwZIJiuAamdjv3lLHDzs8vv5kC31anEZohhUe3qJ2bA==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.21.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "@clerk/react": "^6.4.3", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "@clerk/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/convex-test": { + "version": "0.0.41", + "resolved": "https://registry.npmjs.org/convex-test/-/convex-test-0.0.41.tgz", + "integrity": "sha512-GPHeYFOi70n7UtW0eCEQFVhzl/+m8PvbWkDCbKpHLybI1MrScf4sVpGeM0cC2qmtxiduxa2nLPbehPalhh9oyQ==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.16.4" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..e399f1e2 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "icarus", + "type": "module", + "private": true, + "scripts": { + "audit:convex-contract": "node tool/audit_convex_contract.mjs", + "audit:convex-architecture": "fvm flutter test test/convex_architecture_test.dart", + "check:convex-web-binding": "node tool/check_convex_web_binding.mjs", + "snapshot:convex-contract": "node tool/snapshot_convex_contract.mjs", + "snapshot:convex-contract:check": "node tool/snapshot_convex_contract.mjs --check", + "test:convex": "vitest run --config vitest.config.ts" + }, + "devDependencies": { + "@edge-runtime/vm": "^3.2.0", + "@types/bun": "latest", + "convex-test": "^0.0.41", + "vitest": "^1.6.1" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "convex": "1.45.0" + } +} diff --git a/packages/custom_mouse_cursor/lib/custom_mouse_cursor.dart b/packages/custom_mouse_cursor/lib/custom_mouse_cursor.dart index 595d505a..7ce9af9f 100644 --- a/packages/custom_mouse_cursor/lib/custom_mouse_cursor.dart +++ b/packages/custom_mouse_cursor/lib/custom_mouse_cursor.dart @@ -1728,15 +1728,15 @@ class CustomMouseCursor extends MouseCursor { } } - static math.Random? randomGenerator; - + static math.Random? randomGenerator; + /// Generate a 16 digit random key to use for cursor's key on non web platforms. static String generateUniqueKey() { // md5 seemed overkill, replaced with random key.. //return md5.convert(input).toString(); // generate a safe/random unique 16 digit id for the cursor // THIS IS A QUICK HACKED version.. - // (add [generateUniqueKeysGenerated] count so we get a new seed even if we somehow are still on the + // (add [generateUniqueKeysGenerated] count so we get a new seed even if we somehow are still on the // same microsecond clock time randomGenerator ??= math.Random(DateTime.now().microsecond); diff --git a/packages/custom_mouse_cursor/macos/Classes/CustomMouseCursorPlugin.swift b/packages/custom_mouse_cursor/macos/Classes/CustomMouseCursorPlugin.swift index 866eb90a..6ed5661d 100644 --- a/packages/custom_mouse_cursor/macos/Classes/CustomMouseCursorPlugin.swift +++ b/packages/custom_mouse_cursor/macos/Classes/CustomMouseCursorPlugin.swift @@ -3,7 +3,7 @@ import FlutterMacOS public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { private var caches: Dictionary = [String: NSCursor](); - + public static func register(with registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "custom_mouse_cursor", binaryMessenger: registrar.messenger) let instance = CustomMouseCursorPlugin() @@ -66,7 +66,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { caches[name] = cursor; return name } - + private func setCustomCursor(_ arguments: Dictionary) -> Bool { let name = arguments["name"] as! String let cursor = caches[name]; @@ -76,7 +76,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { cursor!.set() return true } - + private func deleteCustomCursor(_ arguments: Dictionary) -> Bool { let name = arguments["name"] as! String let cursor = caches[name]; @@ -86,7 +86,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { caches.removeValue(forKey: name) return true } - + private func activateMemoryImageCursor(_ arguments: Dictionary) { let buffer = arguments["buffer"] as! FlutterStandardTypedData let byte = [UInt8](buffer.data); @@ -101,7 +101,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { let cursor = getCursorFromImage(image: image!, x: arguments["x"] as? Double, y: arguments["y"] as? Double) cursor.set() } - + private func activeCursor(_ arguments: Dictionary) { let path = arguments["path"] as! String let fullPath = Bundle.main.bundlePath + "/Contents/Frameworks/App.framework/Resources/flutter_assets/" + path @@ -110,7 +110,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { y:arguments["y"] as? Double) cursor?.set() } - + private func resize(image: NSImage, w: Int, h: Int) -> NSImage { // var destSize = NSMakeSize(CGFloat(w), CGFloat(h)) // var newImage = NSImage(size: destSize) @@ -120,8 +120,8 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { // newImage.size = destSize return image } - - + + private func getCursorFromFile(path:String,x:Double?,y:Double?) -> NSCursor? { var cursor = caches[path] if(cursor != nil) { @@ -135,7 +135,7 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { caches[path] = cursor return cursor! } - + private func getCursorFromImage(image: NSImage,x:Double?,y:Double?) -> NSCursor { var dx = x; var dy = y; @@ -149,11 +149,11 @@ public class CustomMouseCursorPlugin: NSObject, FlutterPlugin { hotSpot:NSMakePoint(CGFloat(dx!),CGFloat(dy!))) return cursor } - + private func imageFromFile(named:String) -> NSImage?{ return NSImage.init(contentsOfFile:"\(named)"); } - + private func memoryFromImage(data: Data) -> NSImage? { return NSImage.init(data: data) } diff --git a/presence/.gitignore b/presence/.gitignore new file mode 100644 index 00000000..41a25789 --- /dev/null +++ b/presence/.gitignore @@ -0,0 +1,2 @@ +.wrangler/ +node_modules/ diff --git a/presence/README.md b/presence/README.md new file mode 100644 index 00000000..f499fe4d --- /dev/null +++ b/presence/README.md @@ -0,0 +1,74 @@ +# icarus-presence + +Live presence for cloud strategies: who else has a strategy open (the avatars +in the editor's window strip) and where their cursor is on the map. A +Cloudflare Worker with one Durable Object per strategy relays it. Nothing is +stored: cursors live in memory for as long as someone is connected. + +## How a client gets in + +1. The app calls the Convex mutation `presence:issueRoomPass` + (`convex/presence.ts`). Convex checks the caller can view the strategy and + returns the room URL and a pass signed with `PRESENCE_PASS_SECRET`, valid for + two minutes. It returns null if the deployment has no `PRESENCE_URL`, and + the app then shows no presence. +2. The app opens `wss:///v1/rooms/?pass=`. The + Worker verifies the pass (`src/pass.ts`, shared with Convex) and hands the + socket to that strategy's room (`src/room.ts`). +3. The app sends a `renew` with a fresh pass every minute. The room closes any + socket whose pass runs out, so someone whose access is revoked drops out + within two minutes. + +Messages are JSON. From the client: `cursor {page, x, y}` (canonical +attack-side world coordinates, at most 20 a second), `hide`, +`editing {page, groups}`, `renew {pass}`, and the text `ping`, which the +runtime answers with `pong` without waking the room. From the room: +`welcome {self, uid, peers}`, `join {peer}`, `leave {sid}`, +`cursor {sid, page, x, y}`, `hide {sid}`, `editing {sid, page, groups}`, +`renewed {exp}`. Close code 4001 means the pass ran out or a renewal was +refused. + +`editing` names the lineup groups a session is editing on one page: at most 8 +group ids of at most 128 characters each. An empty `groups` means editing +nothing, and the room relays it as `{page: null, groups: []}`. The room sorts +the ids, drops duplicates, relays only a change, and ignores a non-empty +`editing` that arrives within 100 ms of the last one it accepted. An ignored +`editing` is dropped, not delayed, so a client that changes faster than that +must hold the latest state and send it once 100 ms have passed. A clear (empty +`groups`) always goes through, however soon it arrives: dropped, it would +leave the notice on everyone's screen. Each peer in `welcome` and `join` +carries `editing` as `{page, groups}` or null. Clients and rooms that predate +`editing` ignore it, so either side can deploy first. + +## Environments + +| Worker | URL | Paired Convex deployment | +|---|---|---| +| `icarus-presence-dev` (`--env dev`) | `https://icarus-presence-dev.shawnadedeji.workers.dev` | development, `majestic-eel-413` | +| `icarus-presence` (`--env production`) | `https://icarus-presence.shawnadedeji.workers.dev` | production, `basic-dove-69` | + +Each pair shares one secret. Convex holds it with the Worker's URL: + +```sh +# Generate a secret, then set it on both sides of one pair. +npx wrangler secret put PRESENCE_PASS_SECRET --env dev # from presence/ +npx convex env set PRESENCE_PASS_SECRET # CONVEX_DEPLOYMENT=dev:majestic-eel-413 +npx convex env set PRESENCE_URL https://icarus-presence-dev.shawnadedeji.workers.dev +``` + +For production, use `--env production`, `npx convex env set --prod`, and the +production URL. Rotating the secret signs everyone out of presence until they +rejoin (about a minute); nothing else notices. + +## Working on it + +```sh +cd presence +npm ci +npm test # Worker and room, in the real Workers runtime (Miniflare) +npm run typecheck +npm run deploy:dev # wrangler deploy --env dev +``` + +The `Deploy Web` workflow deploys `--env production` on every push to +`icarus-cloud`, before the web build. CI runs the tests and typecheck. diff --git a/presence/package-lock.json b/presence/package-lock.json new file mode 100644 index 00000000..be4ec3ba --- /dev/null +++ b/presence/package-lock.json @@ -0,0 +1,3477 @@ +{ + "name": "icarus-presence", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "icarus-presence", + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.20260927.1", + "typescript": "^5.9.3", + "vitest": "~4.1.0", + "wrangler": "^4.142.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.22.0.tgz", + "integrity": "sha512-OJv/qikkOgxnKxJ5xrLS7zuOLZhc/6iziU+llqZm4tiQf2CJUYwlMuXN68VaWIQebORd1AUx4w6A0oy8XRbuaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "5.20260815.0-alpha", + "wrangler": "4.124.0", + "zod": "4.4.3" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.124.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.124.0.tgz", + "integrity": "sha512-75euoZKjVTJYFy+Xhctt/5JlZL4M6A4xmovZsUlep+6GHcCm14n9VtdGzNIybOW2t8wuNxRj5iMUwjT5E7Ctog==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260815.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260815.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260815.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260815.1.tgz", + "integrity": "sha512-7PsLdcz6pT9EMd1EJGZEgMyYRfs0CHxGs62PS2L1w3s6+xGmQcRXKm/zoMftmqZF45JBa4MzFeownRKbRt/x5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260815.1.tgz", + "integrity": "sha512-60wtg8ng7FVWeOg/UMbZ9Ye0sslpRRAKoftPbdtuH2volq676quxVr6Zm2EjVULH/JFZeCn72dbLlrnbh0Mpcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260815.1.tgz", + "integrity": "sha512-MuqKIHPo0Qyo8MZMmy0lP2B5PeAL7f4T9Fu4Usk3QdbV4JIrKG/OoybN3Ign7m/Dff+L1Oo/ZHydB+hEg1ueFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260815.1.tgz", + "integrity": "sha512-XNFtJ5rIqJxnY6ISjkfbhT/ODiWJ6LcBvNbntuPD6I/F2k7aZeKgPaXrvWvKde66LXyzFKzc8Hn+Ydx4shevQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260815.1.tgz", + "integrity": "sha512-PiIUWrhbMg3quolwjgMvPOd75vKESjT4aDm7nL6mSjL5IOgmpO/zKstXnYfnEH3pq7sC0UCvKlF8ZPcfsh8NMw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workers-types": { + "version": "5.20260927.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260927.1.tgz", + "integrity": "sha512-7/L0y4Pb8ig/8QSQW4/g4WbGyPYKniw+uC03nkN7VMvawHTWEuqeoVCKi9nOHKiya434eeEUaic2o0+vJhKjYw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.11", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.11", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.11", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miniflare": { + "version": "5.20260815.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260815.0-alpha.tgz", + "integrity": "sha512-YAaGj4Sh5f4fqHKiMQ8zRHDOOM5IGUVtMhnLIeyjuQfU+9P6hcOTrHUVtbfj/ZPay9Kzik4pWELB39pGgefjiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260815.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/vite": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", + "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.9", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workerd": { + "version": "1.20260815.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260815.1.tgz", + "integrity": "sha512-8bArFkHmlp7qFEKVPyNzDzHzS35gc2fg0PYBcDtaNLF7UCDryCX2BQnpkUkTHYIy824IRrHOTwOEoTj0sUO2Fg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260815.1", + "@cloudflare/workerd-darwin-arm64": "1.20260815.1", + "@cloudflare/workerd-linux-64": "1.20260815.1", + "@cloudflare/workerd-linux-arm64": "1.20260815.1", + "@cloudflare/workerd-windows-64": "1.20260815.1" + } + }, + "node_modules/wrangler": { + "version": "4.142.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.142.0.tgz", + "integrity": "sha512-oqquqqtHOevn17hzI7qRzU+MiOaCU2UZRLPIZStjGlhwYbxe3hk4Stq3knfcIlGfMSfgM4iycZKeb1fEF5mRTQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260926.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260926.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260926.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260926.1.tgz", + "integrity": "sha512-VCIpwgJu5Dm1o+VHLclNOKIdpttEqss7oDHS5DYxgVoonM9Dxxxr1xCQvJbh6IXFuQ1DDrbm5krjte5NsTgX3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260926.1.tgz", + "integrity": "sha512-oVuLXfCnr1Xu9sBMNYrEPcFZQNMcviNcacz9l+oqQiLHc5UrVp++lpKbsuWstCfh+/c39Pp37TVeaNBLA8+ReA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260926.1.tgz", + "integrity": "sha512-BjmiDvYBVBG5248gCHSZS3yONbcm3/3qKTu9RaQA8o7jQHJfXx9JnWbhkld2KsbXnRGEGHFwpLLidIpIGtQYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260926.1.tgz", + "integrity": "sha512-28bdfQDIFcpaKXMPisADy8BchpIsX+tEAhD+bhfEBXukFGFr8F3ouyiG3HzN3CqAh/OYvifJizzFZtptUPCy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260926.1.tgz", + "integrity": "sha512-ehdL3ataKdEmlnW9oom6OZUR8n3XsTL/zl8+KGREpLi5sMSW0mek0PrF4wK9SX51f3Alnlu8tF8eKBg+mc3xFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "5.20260926.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260926.0-alpha.tgz", + "integrity": "sha512-m/M3rpLnaouw6QSoKzylfw6Z1dnOwRM48idnZ7kc5ivRqBXANFQ8XqKCyQ/V+xqQ9Rg1L99j4WkZGVU9uT4L2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.0", + "workerd": "1.20260926.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260926.1.tgz", + "integrity": "sha512-YojhsWBuZwk3Jk9OC0QfTgVbLeQOD1tK1olC9ZXAUcmeip8z4NE/xtttpxCsuTvQBRqppvy7Tkac0kxPYMjIXA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260926.1", + "@cloudflare/workerd-darwin-arm64": "1.20260926.1", + "@cloudflare/workerd-linux-64": "1.20260926.1", + "@cloudflare/workerd-linux-arm64": "1.20260926.1", + "@cloudflare/workerd-windows-64": "1.20260926.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/presence/package.json b/presence/package.json new file mode 100644 index 00000000..6c94b425 --- /dev/null +++ b/presence/package.json @@ -0,0 +1,19 @@ +{ + "name": "icarus-presence", + "private": true, + "type": "module", + "scripts": { + "dev": "wrangler dev", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "deploy:dev": "wrangler deploy --env dev", + "deploy:production": "wrangler deploy --env production" + }, + "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.22.0", + "@cloudflare/workers-types": "^5.20260927.1", + "typescript": "^5.9.3", + "vitest": "~4.1.0", + "wrangler": "^4.142.0" + } +} diff --git a/presence/src/index.ts b/presence/src/index.ts new file mode 100644 index 00000000..7218c980 --- /dev/null +++ b/presence/src/index.ts @@ -0,0 +1,36 @@ +import { verifyPass } from "./pass"; +import { CLAIMS_HEADER, type Env } from "./room"; + +export { PresenceRoom } from "./room"; + +// GET /v1/rooms/?pass=, upgraded to a WebSocket. +// Browsers can't set headers on a WebSocket, so the pass rides in the query. +const ROOM_PATH = /^\/v1\/rooms\/([A-Za-z0-9_-]{1,128})$/; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const match = ROOM_PATH.exec(url.pathname); + if (match === null) return new Response("Not found", { status: 404 }); + if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") { + return new Response("Expected a WebSocket upgrade", { status: 426 }); + } + + const room = match[1]!; + const claims = await verifyPass( + url.searchParams.get("pass") ?? "", + env.PRESENCE_PASS_SECRET, + Date.now(), + ); + if (claims === null || claims.room !== room) { + return new Response("Invalid or expired pass", { status: 401 }); + } + + // The room gets the verified claims, never the pass itself. + const headers = new Headers(request.headers); + headers.set(CLAIMS_HEADER, JSON.stringify(claims)); + url.search = ""; + const stub = env.PRESENCE_ROOM.get(env.PRESENCE_ROOM.idFromName(room)); + return stub.fetch(new Request(url, { headers })); + }, +} satisfies ExportedHandler; diff --git a/presence/src/pass.ts b/presence/src/pass.ts new file mode 100644 index 00000000..b874547c --- /dev/null +++ b/presence/src/pass.ts @@ -0,0 +1,115 @@ +// A room pass is Convex's word that a signed-in user may open one strategy's +// room until `exp`. Convex signs it (convex/lib/presencePass.ts, same format) +// with the secret both sides share; this Worker only verifies. +// +// Format: "v1..", where the +// MAC covers "v1.". + +export type PassRole = "owner" | "editor" | "viewer"; + +export interface PassClaims { + /** Strategy public id; one room per strategy. */ + room: string; + /** Opaque, stable per user. Two tabs of one user share it. */ + uid: string; + name: string; + avatar: string | null; + role: PassRole; + /** Expiry, epoch milliseconds. */ + exp: number; +} + +const PREFIX = "v1"; +const encoder = new TextEncoder(); + +export async function signPass( + claims: PassClaims, + secret: string, +): Promise { + const body = `${PREFIX}.${base64UrlEncode(encoder.encode(JSON.stringify(claims)))}`; + const mac = await hmac(secret, body); + return `${body}.${base64UrlEncode(mac)}`; +} + +/** The claims of a valid, unexpired pass, or null. */ +export async function verifyPass( + pass: string, + secret: string, + now: number, +): Promise { + const parts = pass.split("."); + if (parts.length !== 3 || parts[0] !== PREFIX) return null; + const [, encodedClaims, encodedMac] = parts as [string, string, string]; + const mac = base64UrlDecode(encodedMac); + if (mac === null) return null; + const key = await hmacKey(secret); + const valid = await crypto.subtle.verify( + "HMAC", + key, + mac, + encoder.encode(`${PREFIX}.${encodedClaims}`), + ); + if (!valid) return null; + + const claimBytes = base64UrlDecode(encodedClaims); + if (claimBytes === null) return null; + let claims: unknown; + try { + claims = JSON.parse(new TextDecoder().decode(claimBytes)); + } catch { + return null; + } + if (!isPassClaims(claims) || claims.exp <= now) return null; + return claims; +} + +function isPassClaims(value: unknown): value is PassClaims { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + return ( + typeof c.room === "string" && + typeof c.uid === "string" && + typeof c.name === "string" && + (c.avatar === null || typeof c.avatar === "string") && + (c.role === "owner" || c.role === "editor" || c.role === "viewer") && + typeof c.exp === "number" + ); +} + +async function hmacKey(secret: string): Promise { + return crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); +} + +async function hmac(secret: string, message: string): Promise { + const key = await hmacKey(secret); + return new Uint8Array( + await crypto.subtle.sign("HMAC", key, encoder.encode(message)), + ); +} + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +function base64UrlDecode(text: string): Uint8Array | null { + // A length of 1 mod 4 can't come from any byte string. + if (!/^[A-Za-z0-9_-]*$/.test(text) || text.length % 4 === 1) return null; + const padded = text.replace(/-/g, "+").replace(/_/g, "/"); + let binary: string; + try { + binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)); + } catch { + return null; + } + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} diff --git a/presence/src/room.ts b/presence/src/room.ts new file mode 100644 index 00000000..39b1a67b --- /dev/null +++ b/presence/src/room.ts @@ -0,0 +1,390 @@ +import { DurableObject } from "cloudflare:workers"; +import { verifyPass, type PassClaims, type PassRole } from "./pass"; + +// One room per strategy. The room is a relay: it remembers who is connected, +// where their cursor last was, and which lineup groups they are editing, only +// for as long as they are connected. Nothing here is written to storage; a +// cursor is worth nothing a second after it moves. + +export interface Env { + PRESENCE_ROOM: DurableObjectNamespace; + PRESENCE_PASS_SECRET: string; +} + +export interface Cursor { + page: string; + x: number; + y: number; +} + +/** The lineup groups one session is editing on one page. Never empty. */ +export interface Editing { + page: string; + groups: string[]; +} + +export interface Peer { + sid: string; + uid: string; + name: string; + avatar: string | null; + role: PassRole; + cursor: Cursor | null; + editing: Editing | null; +} + +/** Everything the room knows about one socket. Survives hibernation. */ +interface Attachment extends Peer { + room: string; + exp: number; + lastCursorAt: number; + lastEditingAt: number; + lastRenewAt: number; +} + +export const MAX_PEERS = 25; +/** A few windows each; one pass can't fill the room. */ +export const MAX_SESSIONS_PER_USER = 4; +/** How often the room looks for passes that ran out without a renewal. */ +export const SWEEP_INTERVAL_MS = 30_000; +/** Faster than any client sends (20/s); only a misbehaving client hits it. */ +const MIN_CURSOR_INTERVAL_MS = 25; +/** Editing follows clicks, not pointer motion; anything faster is ignored. */ +const MIN_EDITING_INTERVAL_MS = 100; +/** Clients renew once a minute; anything faster is ignored unverified. */ +const MIN_RENEW_INTERVAL_MS = 10_000; +const MAX_MESSAGE_CHARS = 2048; +const MAX_PAGE_ID_CHARS = 128; +const MAX_GROUP_ID_CHARS = 128; +const MAX_EDITING_GROUPS = 8; +/** The map is 1000 units tall and 1778 wide; allow the margin around it. */ +const COORDINATE_LIMIT = 10_000; + +export const CLOSE_PASS_EXPIRED = 4001; + +/** Header the Worker uses to hand verified claims to the room. */ +export const CLAIMS_HEADER = "X-Icarus-Presence-Claims"; + +export class PresenceRoom extends DurableObject { + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + // Keepalives are answered without waking the room. + ctx.setWebSocketAutoResponse( + new WebSocketRequestResponsePair("ping", "pong"), + ); + } + + override async fetch(request: Request): Promise { + const claims = JSON.parse( + request.headers.get(CLAIMS_HEADER) ?? "null", + ) as PassClaims | null; + if (claims === null) return new Response("Missing claims", { status: 400 }); + + const others = this.peers(); + if (others.length >= MAX_PEERS) { + return new Response("Room is full", { status: 503 }); + } + if (others.filter((p) => p.uid === claims.uid).length >= MAX_SESSIONS_PER_USER) { + return new Response("Too many sessions", { status: 429 }); + } + + const pair = new WebSocketPair(); + const [client, server] = [pair[0], pair[1]]; + this.ctx.acceptWebSocket(server); + + const self: Attachment = { + sid: crypto.randomUUID(), + uid: claims.uid, + name: claims.name, + avatar: claims.avatar, + role: claims.role, + cursor: null, + editing: null, + room: claims.room, + exp: claims.exp, + lastCursorAt: 0, + lastEditingAt: 0, + lastRenewAt: 0, + }; + server.serializeAttachment(self); + send(server, { + t: "welcome", + self: self.sid, + uid: self.uid, + peers: others.map(toPeer), + }); + this.broadcast({ t: "join", peer: toPeer(self) }, server); + await this.scheduleSweep(); + return new Response(null, { status: 101, webSocket: client }); + } + + override async webSocketMessage( + ws: WebSocket, + raw: string | ArrayBuffer, + ): Promise { + const self = attachmentOf(ws); + if (self === null) return; + if (self.exp <= Date.now()) { + this.drop(ws, self, CLOSE_PASS_EXPIRED, "Pass expired"); + return; + } + if (typeof raw !== "string" || raw.length > MAX_MESSAGE_CHARS) return; + let message: unknown; + try { + message = JSON.parse(raw); + } catch { + return; + } + if (typeof message !== "object" || message === null) return; + const m = message as Record; + + switch (m.t) { + case "cursor": { + const cursor = parseCursor(m); + const now = Date.now(); + if (cursor === null || now - self.lastCursorAt < MIN_CURSOR_INTERVAL_MS) { + return; + } + ws.serializeAttachment({ ...self, cursor, lastCursorAt: now }); + this.broadcast({ t: "cursor", sid: self.sid, ...cursor }, ws); + return; + } + case "hide": { + if (self.cursor === null) return; + ws.serializeAttachment({ ...self, cursor: null }); + this.broadcast({ t: "hide", sid: self.sid }, ws); + return; + } + case "editing": { + const editing = parseEditing(m); + const now = Date.now(); + // A clear always goes through: dropped, it would leave the notice + // on everyone's screen. It only broadcasts after an accepted change, + // so it cannot flood the room. + if ( + editing === undefined || + (editing !== null && + now - self.lastEditingAt < MIN_EDITING_INTERVAL_MS) || + sameEditing(editing, self.editing) + ) { + return; + } + ws.serializeAttachment({ ...self, editing, lastEditingAt: now }); + this.broadcast( + editing === null + ? { t: "editing", sid: self.sid, page: null, groups: [] } + : { t: "editing", sid: self.sid, ...editing }, + ws, + ); + return; + } + case "renew": { + const now = Date.now(); + if (now - self.lastRenewAt < MIN_RENEW_INTERVAL_MS) return; + ws.serializeAttachment({ ...self, lastRenewAt: now }); + const claims = + typeof m.pass === "string" + ? await verifyPass(m.pass, this.env.PRESENCE_PASS_SECRET, now) + : null; + // A renewal is for the same person in the same room. Anything else is + // a client bug or a forgery; either way the socket is done. + if (claims === null || claims.uid !== self.uid || claims.room !== self.room) { + this.drop(ws, self, CLOSE_PASS_EXPIRED, "Invalid renewal"); + return; + } + // Verifying awaited: the cursor or what is being edited may have + // changed meanwhile, or the socket closed. Renew what it holds now, + // never the state from before the wait. + const current = attachmentOf(ws); + if (current === null) return; + const renewed: Attachment = { + ...current, + lastRenewAt: now, + exp: claims.exp, + name: claims.name, + avatar: claims.avatar, + role: claims.role, + }; + ws.serializeAttachment(renewed); + send(ws, { t: "renewed", exp: claims.exp }); + // Name, avatar, or role may have changed since the last pass; a join + // for a known sid replaces that peer. + if ( + renewed.name !== current.name || + renewed.avatar !== current.avatar || + renewed.role !== current.role + ) { + this.broadcast({ t: "join", peer: toPeer(renewed) }, ws); + } + return; + } + } + } + + override async webSocketClose(ws: WebSocket, code: number): Promise { + const self = attachmentOf(ws); + if (self !== null) { + ws.serializeAttachment(null); + this.broadcast({ t: "leave", sid: self.sid }, ws); + } + // 1005 and 1006 are reserved codes that can't be sent back. + try { + ws.close(code === 1005 || code === 1006 ? 1000 : code, "Closed"); + } catch { + // Already closed. + } + } + + override async webSocketError(ws: WebSocket): Promise { + const self = attachmentOf(ws); + if (self !== null) { + ws.serializeAttachment(null); + this.broadcast({ t: "leave", sid: self.sid }, ws); + } + } + + /** Closes sockets whose pass ran out; keeps sweeping while anyone is here. */ + override async alarm(): Promise { + this.dropExpired(); + await this.scheduleSweep(); + } + + /** + * Wakes the room when the next pass runs out, and at least every + * SWEEP_INTERVAL_MS, so a silent socket whose access was revoked is closed + * on time. Nothing to do once the room is empty. + */ + private async scheduleSweep(): Promise { + const peers = this.peers(); + if (peers.length === 0) return; + const now = Date.now(); + const nextExpiry = Math.min(...peers.map((p) => p.exp)); + await this.ctx.storage.setAlarm( + Math.max(now + 1_000, Math.min(now + SWEEP_INTERVAL_MS, nextExpiry)), + ); + } + + private dropExpired(): void { + const now = Date.now(); + for (const ws of this.ctx.getWebSockets()) { + const self = attachmentOf(ws); + if (self !== null && self.exp <= now) { + this.drop(ws, self, CLOSE_PASS_EXPIRED, "Pass expired"); + } + } + } + + private peers(): Attachment[] { + const peers: Attachment[] = []; + for (const ws of this.ctx.getWebSockets()) { + const peer = attachmentOf(ws); + if (peer !== null) peers.push(peer); + } + return peers; + } + + private drop(ws: WebSocket, self: Attachment, code: number, reason: string) { + // Clear the attachment first so the socket stops counting as a peer even + // before the runtime finishes closing it. + ws.serializeAttachment(null); + try { + ws.close(code, reason); + } catch { + // Already closed. + } + this.broadcast({ t: "leave", sid: self.sid }, ws); + } + + /** Sends to everyone else whose pass is still good. */ + private broadcast(message: object, except: WebSocket): void { + this.dropExpired(); + const text = JSON.stringify(message); + for (const ws of this.ctx.getWebSockets()) { + if (ws === except || attachmentOf(ws) === null) continue; + try { + ws.send(text); + } catch { + // The runtime reports the close separately. + } + } + } +} + +function attachmentOf(ws: WebSocket): Attachment | null { + return (ws.deserializeAttachment() as Attachment | null) ?? null; +} + +function toPeer(a: Attachment): Peer { + return { + sid: a.sid, + uid: a.uid, + name: a.name, + avatar: a.avatar, + role: a.role, + cursor: a.cursor, + editing: a.editing, + }; +} + +function send(ws: WebSocket, message: object): void { + ws.send(JSON.stringify(message)); +} + +function parseCursor(m: Record): Cursor | null { + const { page, x, y } = m; + if ( + typeof page !== "string" || + page.length === 0 || + page.length > MAX_PAGE_ID_CHARS || + !isCoordinate(x) || + !isCoordinate(y) + ) { + return null; + } + return { page, x, y }; +} + +/** + * The groups an `editing` message names, sorted with duplicates dropped, or + * null when it names none. Undefined when the message is malformed. + */ +function parseEditing(m: Record): Editing | null | undefined { + const { page, groups } = m; + if ( + typeof page !== "string" || + page.length === 0 || + page.length > MAX_PAGE_ID_CHARS || + !Array.isArray(groups) || + !groups.every(isGroupId) + ) { + return undefined; + } + const unique = [...new Set(groups)].sort(); + if (unique.length > MAX_EDITING_GROUPS) return undefined; + return unique.length === 0 ? null : { page, groups: unique }; +} + +function isGroupId(value: unknown): value is string { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= MAX_GROUP_ID_CHARS + ); +} + +function sameEditing(a: Editing | null, b: Editing | null): boolean { + if (a === null || b === null) return a === b; + return ( + a.page === b.page && + a.groups.length === b.groups.length && + a.groups.every((group, i) => group === b.groups[i]) + ); +} + +function isCoordinate(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isFinite(value) && + Math.abs(value) <= COORDINATE_LIMIT + ); +} diff --git a/presence/test/env.d.ts b/presence/test/env.d.ts new file mode 100644 index 00000000..db25ecdf --- /dev/null +++ b/presence/test/env.d.ts @@ -0,0 +1,7 @@ +import type { Env as WorkerEnv } from "../src/room"; + +declare global { + namespace Cloudflare { + interface Env extends WorkerEnv {} + } +} diff --git a/presence/test/presence.test.ts b/presence/test/presence.test.ts new file mode 100644 index 00000000..00071471 --- /dev/null +++ b/presence/test/presence.test.ts @@ -0,0 +1,423 @@ +import { env, runDurableObjectAlarm, SELF } from "cloudflare:test"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { signPass, verifyPass, type PassClaims } from "../src/pass"; +import { CLOSE_PASS_EXPIRED } from "../src/room"; + +const SECRET = "test-secret"; +// A fresh room per test, so sockets closing after one test never reach the next. +let roomCount = 0; +let ROOM = ""; +beforeEach(() => { + ROOM = `strat_${++roomCount}`; +}); + +function claims(overrides: Partial = {}): PassClaims { + return { + room: ROOM, + uid: "u-ana", + name: "Ana", + avatar: null, + role: "editor", + exp: Date.now() + 120_000, + ...overrides, + }; +} + +interface Client { + ws: WebSocket; + messages: any[]; + closed: Promise; + next(type: string): Promise; +} + +const open: WebSocket[] = []; + +async function connect(pass: string, room = ROOM): Promise { + const response = await SELF.fetch( + `https://presence.test/v1/rooms/${room}?pass=${encodeURIComponent(pass)}`, + { headers: { Upgrade: "websocket" } }, + ); + const ws = response.webSocket; + if (ws == null) throw new Error(`No socket: ${response.status}`); + ws.accept(); + open.push(ws); + const messages: any[] = []; + const waiters: { type: string; resolve: (m: any) => void }[] = []; + ws.addEventListener("message", (event: MessageEvent) => { + const message = JSON.parse(event.data as string); + const i = waiters.findIndex((w) => w.type === message.t); + if (i >= 0) waiters.splice(i, 1)[0]!.resolve(message); + else messages.push(message); + }); + const closed = new Promise((resolve) => + ws.addEventListener("close", resolve), + ); + return { + ws, + messages, + closed, + next(type) { + const i = messages.findIndex((m) => m.t === type); + if (i >= 0) return Promise.resolve(messages.splice(i, 1)[0]); + return new Promise((resolve) => waiters.push({ type, resolve })); + }, + }; +} + +function base64Url(text: string): string { + return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +afterEach(() => { + for (const ws of open.splice(0)) { + try { + ws.close(1000); + } catch { + // Already closed. + } + } +}); + +describe("room pass", () => { + it("round-trips valid claims", async () => { + const c = claims(); + expect(await verifyPass(await signPass(c, SECRET), SECRET, Date.now())).toEqual(c); + }); + + it("rejects a wrong secret, a tampered body, a missing MAC, and expiry", async () => { + const pass = await signPass(claims(), SECRET); + const [version, body, mac] = pass.split("."); + const forged = base64Url(JSON.stringify(claims({ role: "owner" }))); + expect(await verifyPass(pass, "other", Date.now())).toBeNull(); + expect(await verifyPass(`${version}.${forged}.${mac}`, SECRET, Date.now())).toBeNull(); + expect(await verifyPass(`${version}.${body}`, SECRET, Date.now())).toBeNull(); + expect(await verifyPass(pass, SECRET, Date.now() + 121_000)).toBeNull(); + }); + + it("rejects malformed base64 instead of throwing", async () => { + expect(await verifyPass("v1.a.a", SECRET, Date.now())).toBeNull(); + expect(await verifyPass("v1.abcde.ab", SECRET, Date.now())).toBeNull(); + const response = await SELF.fetch(`https://presence.test/v1/rooms/${ROOM}?pass=v1.a.a`, { + headers: { Upgrade: "websocket" }, + }); + expect(response.status).toBe(401); + }); +}); + +describe("worker", () => { + it("refuses a pass for another room", async () => { + const pass = await signPass(claims({ room: "other_room" }), SECRET); + const response = await SELF.fetch( + `https://presence.test/v1/rooms/${ROOM}?pass=${pass}`, + { headers: { Upgrade: "websocket" } }, + ); + expect(response.status).toBe(401); + }); + + it("refuses an expired pass, plain HTTP, and unknown paths", async () => { + const expired = await signPass(claims({ exp: Date.now() - 1 }), SECRET); + const url = `https://presence.test/v1/rooms/${ROOM}?pass=${expired}`; + const upgrade = { headers: { Upgrade: "websocket" } }; + expect((await SELF.fetch(url, upgrade)).status).toBe(401); + expect((await SELF.fetch(url)).status).toBe(426); + expect((await SELF.fetch("https://presence.test/")).status).toBe(404); + }); +}); + +describe("room", () => { + it("welcomes, announces joins, relays cursors, and announces leaves", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + expect(await ana.next("welcome")).toMatchObject({ uid: "u-ana", peers: [] }); + + const ben = await connect( + await signPass(claims({ uid: "u-ben", name: "Ben", role: "viewer" }), SECRET), + ); + const benWelcome = await ben.next("welcome"); + expect(benWelcome.peers.map((p: any) => p.name)).toEqual(["Ana"]); + expect((await ana.next("join")).peer).toMatchObject({ + sid: benWelcome.self, + name: "Ben", + role: "viewer", + cursor: null, + }); + + ben.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 400, y: 300 })); + expect(await ana.next("cursor")).toEqual({ + t: "cursor", + sid: benWelcome.self, + page: "p1", + x: 400, + y: 300, + }); + + // A newcomer sees where an existing cursor already is. + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const cyWelcome = await cy.next("welcome"); + const benSeenByCy = cyWelcome.peers.find((p: any) => p.name === "Ben"); + expect(benSeenByCy.cursor).toEqual({ page: "p1", x: 400, y: 300 }); + + ben.ws.send(JSON.stringify({ t: "hide" })); + expect(await ana.next("hide")).toEqual({ t: "hide", sid: benWelcome.self }); + + ben.ws.close(1000, "bye"); + expect(await ana.next("leave")).toEqual({ t: "leave", sid: benWelcome.self }); + expect(await cy.next("leave")).toEqual({ t: "leave", sid: benWelcome.self }); + }); + + it("ignores malformed cursors", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const ben = await connect(await signPass(claims({ uid: "u-ben", name: "Ben" }), SECRET)); + await ben.next("welcome"); + await ana.next("join"); + + ben.ws.send("not json"); + ben.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: "1", y: 2 })); + ben.ws.send(JSON.stringify({ t: "cursor", page: "", x: 1, y: 2 })); + ben.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 1e9, y: 2 })); + ben.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 5, y: 6 })); + expect(await ana.next("cursor")).toMatchObject({ x: 5, y: 6 }); + expect(ana.messages.filter((m) => m.t === "cursor")).toEqual([]); + }); + + it("renews a pass for the same person and closes on a forged renewal", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const exp = Date.now() + 240_000; + ana.ws.send(JSON.stringify({ t: "renew", pass: await signPass(claims({ exp }), SECRET) })); + expect(await ana.next("renewed")).toEqual({ t: "renewed", exp }); + + const eve = await connect(await signPass(claims({ uid: "u-eve" }), SECRET)); + await eve.next("welcome"); + eve.ws.send( + JSON.stringify({ t: "renew", pass: await signPass(claims({ uid: "u-ana" }), SECRET) }), + ); + expect((await eve.closed).code).toBe(CLOSE_PASS_EXPIRED); + }); + + it("ignores renewals faster than one per ten seconds", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + ana.ws.send(JSON.stringify({ t: "renew", pass: await signPass(claims(), SECRET) })); + await ana.next("renewed"); + ana.ws.send(JSON.stringify({ t: "renew", pass: "garbage" })); + await sleep(100); + expect(ana.messages).toEqual([]); + ana.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 1, y: 1 })); + await sleep(50); + // Still connected: the garbage renewal was never even checked. + expect(ana.ws.readyState).toBe(WebSocket.READY_STATE_OPEN); + }); + + it("stops sending to a silent socket once its pass runs out", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const ben = await connect( + await signPass(claims({ uid: "u-ben", name: "Ben", exp: Date.now() + 300 }), SECRET), + ); + await ben.next("welcome"); + await ana.next("join"); + await sleep(400); + + // No alarm yet; Ana's cursor is the first thing that happens. + ana.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 3, y: 4 })); + expect((await ben.closed).code).toBe(CLOSE_PASS_EXPIRED); + expect(ben.messages.filter((m) => m.t === "cursor")).toEqual([]); + }); + + it("limits one person to a few sessions", async () => { + const pass = await signPass(claims(), SECRET); + for (let i = 0; i < 4; i++) await (await connect(pass)).next("welcome"); + const response = await SELF.fetch( + `https://presence.test/v1/rooms/${ROOM}?pass=${encodeURIComponent(pass)}`, + { headers: { Upgrade: "websocket" } }, + ); + expect(response.status).toBe(429); + // Someone else still gets in. + const ben = await connect(await signPass(claims({ uid: "u-ben" }), SECRET)); + expect((await ben.next("welcome")).peers).toHaveLength(4); + }); + + it("closes sockets whose pass ran out at the next sweep", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const ben = await connect( + await signPass(claims({ uid: "u-ben", name: "Ben", exp: Date.now() + 300 }), SECRET), + ); + const benWelcome = await ben.next("welcome"); + await ana.next("join"); + await sleep(400); + + const stub = env.PRESENCE_ROOM.get(env.PRESENCE_ROOM.idFromName(ROOM)); + expect(await runDurableObjectAlarm(stub)).toBe(true); + expect((await ben.closed).code).toBe(CLOSE_PASS_EXPIRED); + expect(await ana.next("leave")).toEqual({ t: "leave", sid: benWelcome.self }); + }); + + it("keeps rooms apart", async () => { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const other = await connect( + await signPass(claims({ room: "other_room", uid: "u-ben" }), SECRET), + "other_room", + ); + expect((await other.next("welcome")).peers).toEqual([]); + other.ws.send(JSON.stringify({ t: "cursor", page: "p1", x: 1, y: 1 })); + await sleep(100); + expect(ana.messages).toEqual([]); + }); +}); + +describe("editing", () => { + // Longer than the room's 100 ms editing interval. + const PAST_INTERVAL_MS = 150; + + /** Ana and Ben in one room, with every arrival message already consumed. */ + async function anaAndBen() { + const ana = await connect(await signPass(claims(), SECRET)); + await ana.next("welcome"); + const ben = await connect(await signPass(claims({ uid: "u-ben", name: "Ben" }), SECRET)); + const benSid = (await ben.next("welcome")).self as string; + await ana.next("join"); + return { ana, ben, benSid }; + } + + function editing(page: unknown, groups: unknown): string { + return JSON.stringify({ t: "editing", page, groups }); + } + + it("relays editing to others without echoing it back", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g2", "g1", "g2"])); + expect(await ana.next("editing")).toEqual({ + t: "editing", + sid: benSid, + page: "p1", + groups: ["g1", "g2"], + }); + await sleep(100); + expect(ben.messages).toEqual([]); + }); + + it("tells a newcomer what everyone is editing", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toEqual({ + page: "p1", + groups: ["g1"], + }); + expect(peers.find((p: any) => p.name === "Ana").editing).toBeNull(); + // Peers already in the room hear about Cy, who is editing nothing. + expect((await ana.next("join")).peer.editing).toBeNull(); + }); + + it("broadcasts a clear and forgets it", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + await sleep(PAST_INTERVAL_MS); + + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toEqual({ + t: "editing", + sid: benSid, + page: null, + groups: [], + }); + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toBeNull(); + }); + + it("ignores malformed editing", async () => { + const { ana, ben } = await anaAndBen(); + const long = "g".repeat(129); + const nine = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; + ben.ws.send(editing("p".repeat(129), ["g1"])); + ben.ws.send(editing("p1", [long])); + ben.ws.send(editing("p1", nine)); + ben.ws.send(editing("p1", "g1")); + ben.ws.send(editing("p1", { 0: "g1" })); + ben.ws.send(editing("p1", [""])); + ben.ws.send(editing("p1", ["g1", 2])); + ben.ws.send(JSON.stringify({ t: "editing", groups: ["g1"] })); + ben.ws.send(editing("", ["g1"])); + ben.ws.send(editing(7, ["g1"])); + // Malformed messages don't start the rate limit, so this one lands. + ben.ws.send(editing("p1", ["g1", "g1", "g1", "g1", "g1", "g1", "g1", "g1", "g2"])); + expect(await ana.next("editing")).toMatchObject({ page: "p1", groups: ["g1", "g2"] }); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + }); + + it("ignores editing faster than one per 100 ms", async () => { + const { ana, ben } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + ben.ws.send(editing("p1", ["g2"])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g1"] }); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + ben.ws.send(editing("p1", ["g3"])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g3"] }); + }); + + it("relays a clear however soon it follows a change", async () => { + const { ana, ben } = await anaAndBen(); + // Sent 150 ms apart, but the network delivered them together. + ben.ws.send(editing("p1", ["g1"])); + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toMatchObject({ groups: ["g1"] }); + expect(await ana.next("editing")).toMatchObject({ page: null, groups: [] }); + }); + + it("doesn't rebroadcast an unchanged editing", async () => { + const { ana, ben } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1", "g2"])); + await ana.next("editing"); + await sleep(PAST_INTERVAL_MS); + // The same set in another order is the same editing. + ben.ws.send(editing("p1", ["g2", "g1"])); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + // Neither is an empty clear while editing nothing. + ben.ws.send(editing("p1", [])); + expect(await ana.next("editing")).toMatchObject({ page: null, groups: [] }); + await sleep(PAST_INTERVAL_MS); + ben.ws.send(editing("p2", [])); + await sleep(PAST_INTERVAL_MS); + expect(ana.messages.filter((m) => m.t === "editing")).toEqual([]); + + // The same groups on another page is a change. + ben.ws.send(editing("p2", ["g1", "g2"])); + expect(await ana.next("editing")).toMatchObject({ page: "p2", groups: ["g1", "g2"] }); + }); + + it("keeps editing through a renewal and its profile refresh", async () => { + const { ana, ben, benSid } = await anaAndBen(); + ben.ws.send(editing("p1", ["g1"])); + await ana.next("editing"); + + const pass = await signPass(claims({ uid: "u-ben", name: "Benjamin" }), SECRET); + ben.ws.send(JSON.stringify({ t: "renew", pass })); + await ben.next("renewed"); + expect((await ana.next("join")).peer).toMatchObject({ + sid: benSid, + name: "Benjamin", + editing: { page: "p1", groups: ["g1"] }, + }); + + const cy = await connect(await signPass(claims({ uid: "u-cy", name: "Cy" }), SECRET)); + const peers = (await cy.next("welcome")).peers; + expect(peers.find((p: any) => p.sid === benSid).editing).toEqual({ + page: "p1", + groups: ["g1"], + }); + }); +}); diff --git a/presence/tsconfig.json b/presence/tsconfig.json new file mode 100644 index 00000000..bb62bf79 --- /dev/null +++ b/presence/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["@cloudflare/workers-types", "@cloudflare/vitest-pool-workers/types"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/presence/vitest.config.ts b/presence/vitest.config.ts new file mode 100644 index 00000000..3e41cc9a --- /dev/null +++ b/presence/vitest.config.ts @@ -0,0 +1,11 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.jsonc" }, + miniflare: { bindings: { PRESENCE_PASS_SECRET: "test-secret" } }, + }), + ], +}); diff --git a/presence/wrangler.jsonc b/presence/wrangler.jsonc new file mode 100644 index 00000000..ea087cd7 --- /dev/null +++ b/presence/wrangler.jsonc @@ -0,0 +1,32 @@ +// Live presence and cursors for cloud strategies. One Durable Object per +// strategy relays who is in it and where their cursor is; nothing is stored. +// Convex signs the room pass (convex/presence.ts) with PRESENCE_PASS_SECRET, +// which each environment sets as a Worker secret matching its Convex +// deployment: dev pairs with the development deployment, production with the +// production one. See presence/README.md. +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "icarus-presence", + "main": "src/index.ts", + "compatibility_date": "2026-08-15", + "durable_objects": { + "bindings": [{ "name": "PRESENCE_ROOM", "class_name": "PresenceRoom" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["PresenceRoom"] }], + // The room pass rides in the query string; keep it out of the logs. + "observability": { "enabled": true, "redact_query_string": true }, + "env": { + "dev": { + "name": "icarus-presence-dev", + "durable_objects": { + "bindings": [{ "name": "PRESENCE_ROOM", "class_name": "PresenceRoom" }] + } + }, + "production": { + "name": "icarus-presence", + "durable_objects": { + "bindings": [{ "name": "PRESENCE_ROOM", "class_name": "PresenceRoom" }] + } + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 88f67e05..86fd7162 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,14 +25,46 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.4" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: "direct main" description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.2.0" args: dependency: transitive description: @@ -45,10 +77,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -61,10 +93,10 @@ packages: dependency: transitive description: name: boxy - sha256: "569373f23560f5a5dbe53c08a7463a698635e7ac72ba355ff4fa52516c0d2e32" + sha256: "42ccafe13b2893878042acc5b7e2446025328e11a3197b0bb78db42ff76aa3f0" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.0" buffer: dependency: transitive description: @@ -81,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.4" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" build_config: dependency: transitive description: @@ -93,10 +133,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.5" build_resolvers: dependency: transitive description: @@ -133,18 +173,18 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.7" chalkdart: dependency: transitive description: name: chalkdart - sha256: "7ffc6bd39c81453fb9ba8dbce042a9c960219b75ea1c07196a7fa41c2fab9e86" + sha256: "7dcf37e0b3d8dcec8c4ae0420d85aad9b3167d6a759601fd5b0f2b1958746581" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.1.0" characters: dependency: transitive description: @@ -165,28 +205,36 @@ packages: dependency: transitive description: name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + sha256: "89b8801ba49f8b593fbf2d3e59efd0f566c46cd4ba6837ae2610c72b52a0f899" url: "https://pub.dev" source: hosted - version: "0.4.2" + version: "0.6.0" clock: - dependency: transitive + dependency: "direct main" description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.12.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -209,14 +257,21 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + convex_flutter: + dependency: "direct main" + description: + path: "third_party/convex_flutter" + relative: true + source: path + version: "3.0.1" cross_file: dependency: "direct main" description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+5" crypto: dependency: "direct dev" description: @@ -245,10 +300,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" custom_lint_core: dependency: transitive description: @@ -272,6 +327,14 @@ packages: relative: true source: path version: "1.1.3" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" dart_style: dependency: transitive description: @@ -300,10 +363,10 @@ packages: dependency: "direct main" description: name: desktop_updater - sha256: abab8542cf02b1ce96428c1fca091e4c627716c6cffbe944b1b6014dd8df17ec + sha256: fac5d65f81a2517ab03a40bea91ab1645da4c84c41aba63107098e3b03884df8 url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" discord_rich_presence: dependency: "direct main" description: @@ -316,18 +379,18 @@ packages: dependency: transitive description: name: equatable - sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.0" extended_image: dependency: transitive description: name: extended_image - sha256: f6cbb1d798f51262ed1a3d93b4f1f2aa0d76128df39af18ecb77fa740f88b2e0 + sha256: e81d801cd692921f152834e01f39bba8b3d6302c48ad420d499c6062a7e24737 url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "10.1.0" extended_image_library: dependency: transitive description: @@ -337,7 +400,7 @@ packages: source: hosted version: "5.0.1" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" @@ -348,10 +411,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -414,10 +477,10 @@ packages: dependency: transitive description: name: flutter_inappwebview_internal_annotations - sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" flutter_inappwebview_ios: dependency: transitive description: @@ -475,10 +538,10 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" url: "https://pub.dev" source: hosted - version: "2.0.33" + version: "2.0.35" flutter_portal: dependency: "direct main" description: @@ -495,6 +558,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" flutter_shaders: dependency: transitive description: @@ -507,10 +578,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -542,22 +613,38 @@ packages: description: flutter source: sdk version: "0.0.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "931e822688d2e97867b2300e2fdbb08c9db73f8653fc83efc7a259d2e0cca769" + url: "https://pub.dev" + source: hosted + version: "2.7.1" get_it: dependency: transitive description: name: get_it - sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + sha256: "568d62f0e68666fb5d95519743b3c24a34c7f19d834b0658c46e26d778461f66" url: "https://pub.dev" source: hosted - version: "8.3.0" + version: "9.2.1" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: cd73a6de4198baca71341177bed233673d7965aca2a0257ab5561feb2abdaf59 + url: "https://pub.dev" + source: hosted + version: "2.27.2" graphs: dependency: transitive description: @@ -566,14 +653,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hive_ce: dependency: "direct main" description: name: hive_ce - sha256: "412c638aeac0f003bba664884e3048b9547e541aaca13f10cc639da788184bed" + sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" url: "https://pub.dev" source: hosted - version: "2.16.0" + version: "2.19.3" hive_ce_flutter: dependency: "direct main" description: @@ -590,6 +685,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" http: dependency: "direct main" description: @@ -622,22 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - iconsax_flutter: - dependency: transitive - description: - name: iconsax_flutter - sha256: d14b4cec8586025ac15276bdd40f6eea308cb85748135965bb6255f14beb2564 - url: "https://pub.dev" - source: hosted - version: "1.0.1" image: dependency: "direct main" description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.9.2" integration_test: dependency: "direct dev" description: flutter @@ -655,18 +750,42 @@ packages: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" isolate_channel: dependency: transitive description: name: isolate_channel - sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d + sha256: a9d3d620695bc984244dafae00b95e4319d6974b2d77f4b9e1eb4f2efe099094 url: "https://pub.dev" source: hosted - version: "0.2.2+1" + version: "0.6.1" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: @@ -767,18 +886,26 @@ packages: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" msix: dependency: "direct dev" description: name: msix - sha256: f88033fcb9e0dd8de5b18897cbebbd28ea30596810f4a7c86b12b0c03ace87e5 + sha256: "61415c352e8aea084332b8a5514e6408c961c8cdeca202804fff1040eb555ac7" url: "https://pub.dev" source: hosted - version: "3.16.12" + version: "3.18.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" package_config: dependency: transitive description: @@ -787,6 +914,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + passkeys_platform_interface: + dependency: transitive + description: + name: passkeys_platform_interface + sha256: e810520c7b79dca629fdc266958564eedd77dc85a955f5e94430dfccb92eef68 + url: "https://pub.dev" + source: hosted + version: "2.9.0" pasteboard: dependency: "direct main" description: @@ -815,42 +950,42 @@ packages: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -871,10 +1006,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -891,46 +1026,78 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" pool: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.2" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: b7f957273ba6a1c26cefa987ac524f4138e9ab7542fbb9e6abdfddacb9895a21 + url: "https://pub.dev" + source: hosted + version: "2.9.1" process: dependency: transitive description: name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "5.0.5" + version: "5.0.6" pub_semver: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: ff731ce21fd775e0d7accfb1eaccdcf47b687610720822ba8b9fd8f5bccb2bd9 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "2.13.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -967,42 +1134,42 @@ packages: dependency: transitive description: name: screen_retriever - sha256: "570dbc8e4f70bac451e0efc9c9bb19fa2d6799a11e6ef04f946d7886d2e23d0c" + sha256: ace919117a7520c13a50a6259e60c4a0d4cbe98809468792a91b5c5adada2aa6 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_linux: dependency: transitive description: name: screen_retriever_linux - sha256: f7f8120c92ef0784e58491ab664d01efda79a922b025ff286e29aa123ea3dd18 + sha256: "7b52006a5ceae1f3d5af7f77188c3290d6e7d8ded16d99809bea84967c65c257" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_macos: dependency: transitive description: name: screen_retriever_macos - sha256: "71f956e65c97315dd661d71f828708bd97b6d358e776f1a30d5aa7d22d78a149" + sha256: a1489b99cce597c45a54b9aae1cd94c8d4705353b7e0bb2457a6e4de44e0ad8a url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_platform_interface: dependency: transitive description: name: screen_retriever_platform_interface - sha256: ee197f4581ff0d5608587819af40490748e1e39e648d7680ecf95c05197240c0 + sha256: "94a5535277510a63184ca178ce12a1449bc0b38618879aa1c18bf57369c5064a" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screen_retriever_windows: dependency: transitive description: name: screen_retriever_windows - sha256: "449ee257f03ca98a57288ee526a301a430a344a161f9202b4fcc38576716fe13" + sha256: dafc6922b0bfbf1d48cf3ccbf519b4fff47bdcb820da1728ea6db675fecc9324 url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.2.2" screenshot: dependency: "direct main" description: @@ -1019,6 +1186,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.40.6" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -1060,10 +1283,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -1080,8 +1303,16 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - stream_channel: + storage_client: dependency: transitive + description: + name: storage_client + sha256: b16aaaadcef812fda31fca056f50fb3485afe64f94e5d537d30f485ed55dec5e + url: "https://pub.dev" + source: hosted + version: "2.8.0" + stream_channel: + dependency: "direct dev" description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" @@ -1092,10 +1323,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -1104,6 +1335,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + supabase: + dependency: "direct main" + description: + name: supabase + sha256: "0645becf76f49d2633d8ec9bfd78ab98dce42de6899da8fa1f069c2c836d5d23" + url: "https://pub.dev" + source: hosted + version: "2.16.1" + supabase_common: + dependency: transitive + description: + name: supabase_common + sha256: a7961a44953cf8d9da049381fc3377516348255042fbb12b9d4f8540816f2fe9 + url: "https://pub.dev" + source: hosted + version: "0.1.2" + supabase_flutter: + dependency: "direct main" + description: + name: supabase_flutter + sha256: "26eb3d3b71bcc5651551e64d6b131ba929ea261998570bbcc90356b1b42fd532" + url: "https://pub.dev" + source: hosted + version: "2.17.2" sync_http: dependency: transitive description: @@ -1132,10 +1387,10 @@ packages: dependency: transitive description: name: theme_extensions_builder_annotation - sha256: e14648a3318842992f14b207cb811df48a1d6f0bd742b77bcbf1b74557493c00 + sha256: "9249af881cd892aeac002bb016628becac51f12439df9af2a23e3c516b0470f5" url: "https://pub.dev" source: hosted - version: "7.1.0" + version: "7.4.0" timing: dependency: transitive description: @@ -1148,18 +1403,18 @@ packages: dependency: "direct main" description: name: toastification - sha256: "69db2bff425b484007409650d8bcd5ed1ce2e9666293ece74dcd917dacf23112" + sha256: "66c96678e3dece8ba24de3ea31634bd65a80aaecb8105f9bafe946e5f0d7590a" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.2.0" two_dimensional_scrollables: dependency: transitive description: name: two_dimensional_scrollables - sha256: "0f77ecb96596f2f82eec2b0a8e60d9305c58315557da9fa3b610c7dbf5ded621" + sha256: d3c4771a7ffc67a16950056742565960d32e729df8a52aa09731e5bb68e106df url: "https://pub.dev" source: hosted - version: "0.3.7" + version: "0.5.4" typed_data: dependency: transitive description: @@ -1172,10 +1427,10 @@ packages: dependency: transitive description: name: universal_image - sha256: ef47a4a002158cf0b36ed3b7605af132d2476cc42703e41b8067d3603705c40d + sha256: "2eae13df84a47960cc4148fec88f09031ea64cbf667b4131ff2c907dd7b7c6d1" url: "https://pub.dev" source: hosted - version: "1.0.11" + version: "1.0.12" universal_platform: dependency: transitive description: @@ -1196,34 +1451,34 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -1236,34 +1491,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" uuid: dependency: "direct main" description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.2.3" vector_graphics_codec: dependency: transitive description: @@ -1276,10 +1531,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.3.0" vector_math: dependency: transitive description: @@ -1292,10 +1547,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.3.0" watcher: dependency: transitive description: @@ -1321,7 +1576,7 @@ packages: source: hosted version: "1.0.1" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 @@ -1344,6 +1599,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: "direct main" + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" window_manager: dependency: "direct main" description: @@ -1380,10 +1643,10 @@ packages: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_writer: dependency: transitive description: @@ -1392,6 +1655,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: eaa26beb5990b25a49d942374fd5a0c5aa67a837e03b14b4c26134aaa1ed01a9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" sdks: - dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 791173b8..8d0d7033 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: "A new Flutter project." publish_to: "none" # Remove this line if you wish to publish to pub.dev -version: 4.6.3+103 #version number +version: 4.6.3+104 #version number environment: sdk: ">=3.4.3 <4.0.0" @@ -13,6 +13,7 @@ dependencies: sdk: flutter cupertino_icons: ^1.0.6 + collection: ^1.19.1 flutter_svg: ^2.0.14 flutter_riverpod: ^2.6.1 json_annotation: ^4.9.0 @@ -30,6 +31,8 @@ dependencies: cross_file: ^0.3.4+2 image: ^4.5.4 http: ^1.6.0 + clock: ^1.1.2 + web_socket_channel: ^3.0.3 screenshot: ^3.0.0 archive: ^4.0.7 flutter_portal: ^1.1.4 @@ -42,6 +45,12 @@ dependencies: pasteboard: ^0.4.0 desktop_updater: ^1.4.0 cryptography_plus: ^2.7.1 + convex_flutter: + path: third_party/convex_flutter + supabase: ^2.10.2 + supabase_flutter: ^2.12.0 + win32_registry: ^2.1.0 + app_links: ^6.4.1 lucide_icons_flutter: 3.1.14+2 xml: ^6.5.0 discord_rich_presence: ^1.1.1 @@ -56,6 +65,8 @@ dependency_overrides: dev_dependencies: crypto: ^3.0.7 + fake_async: ^1.3.3 + stream_channel: ^2.1.4 flutter_test: sdk: flutter integration_test: diff --git a/release/metadata/4.6.1+97.json b/release/metadata/4.6.1+97.json new file mode 100644 index 00000000..eee3b8ae --- /dev/null +++ b/release/metadata/4.6.1+97.json @@ -0,0 +1,29 @@ +{ + "version": "4.6.1+97", + "shortVersion": 97, + "title": "Icarus Online Beta", + "description": "Invite-only cloud strategy sync and sharing for internal testing.", + "date": "2026-09-03", + "mandatory": false, + "channels": [ + "desktop", + "prerelease" + ], + "platforms": [ + "windows" + ], + "changes": [ + { + "message": "Signed-in testers can keep a cloud library and sync strategy changes through the Icarus backend.", + "type": "feature" + }, + { + "message": "Strategies can be shared with view-only or editor access during the online beta.", + "type": "feature" + }, + { + "message": "This build is an internal prerelease. Cloud rollback and the complete two-client release check are not finished yet.", + "type": "other" + } + ] +} diff --git a/scripts/assert_release_preflight.ps1 b/scripts/assert_release_preflight.ps1 new file mode 100644 index 00000000..d16699be --- /dev/null +++ b/scripts/assert_release_preflight.ps1 @@ -0,0 +1,30 @@ +param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, + [string]$BranchName = "" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $ReleaseTarget -BranchName $BranchName | Out-Null + +$cloudReleaseTarget = switch ($ReleaseTarget) { + "stable-desktop" { "stable" } + "prerelease-desktop" { "prerelease" } + "store" { "store" } + default { $null } +} + +if ($null -ne $cloudReleaseTarget) { + Resolve-CloudBuildConfiguration ` + -ReleaseTarget $cloudReleaseTarget ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null +} diff --git a/scripts/build_desktop_release.ps1 b/scripts/build_desktop_release.ps1 index b944ebd7..ba213d0e 100644 --- a/scripts/build_desktop_release.ps1 +++ b/scripts/build_desktop_release.ps1 @@ -12,6 +12,8 @@ param( [string]$InitialChangeMessage = "Describe this release before publishing.", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -30,6 +32,16 @@ if ($runBuild -or $runPackage) { } if ($runBuild) { + # Only the build phase compiles the cloud configuration into the binaries, + # so it is where a stable build must prove it runs from main with production + # Convex settings. Package and stage only handle artifacts built here. + $releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null + $cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId + Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments @("dart", "run", "tool/check_bundled_wall_heights.dart") if (-not $SkipPubGet) { @@ -51,14 +63,21 @@ if ($runBuild) { "--release", "--dart-define=ICARUS_UPDATE_CHANNEL=$Channel" ) + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + } + if ($cloudBuildConfiguration.Environment -eq "production") { + $dartDefines.ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + $dartDefines.ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $releaseArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $releaseArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $releaseArguments } finally { @@ -195,9 +214,7 @@ else { $missingChannels = @($requiredChannels | Where-Object { $channels -notcontains $_ }) if ($missingChannels.Count -gt 0) { - $metadata.channels = @($channels + $missingChannels) - Write-JsonFileUtf8 -Value $metadata -Path $metadataPath -Depth 6 - Write-Host ("Updated release metadata channels at {0}: {1}" -f $metadataPath, ($metadata.channels -join ", ")) + throw "Release metadata '$metadataPath' does not include channel(s): $($missingChannels -join ', '). Review and edit the metadata explicitly before building." } } diff --git a/scripts/build_store_release.ps1 b/scripts/build_store_release.ps1 index ab46b744..df978d98 100644 --- a/scripts/build_store_release.ps1 +++ b/scripts/build_store_release.ps1 @@ -2,6 +2,8 @@ param( [string]$OutputDir = "release\out\store", [string]$PostHogProjectToken = $env:POSTHOG_PROJECT_TOKEN, [string]$PostHogHost = $(if ($env:POSTHOG_HOST) { $env:POSTHOG_HOST } else { "https://us.i.posthog.com" }), + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -11,6 +13,11 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" | Out-Null +$cloudBuildConfiguration = Resolve-CloudBuildConfiguration ` + -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId $env:FLUTTER_ROOT = Get-FlutterRoot -RepoRoot $repoRoot $windowsBuildRoot = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath "build\windows" @@ -32,14 +39,18 @@ try { } $flutterBuildArguments = @("flutter", "build", "windows", "--release") + $dartDefines = [ordered]@{ + ICARUS_CLOUD_ENVIRONMENT = $cloudBuildConfiguration.Environment + ICARUS_CONVEX_DEPLOYMENT_URL = $cloudBuildConfiguration.DeploymentUrl + ICARUS_CONVEX_CLIENT_ID = $cloudBuildConfiguration.ClientId + } if (-not [string]::IsNullOrWhiteSpace($PostHogProjectToken)) { - $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) - Write-JsonFileUtf8 -Path $dartDefinesPath -Value @{ - POSTHOG_PROJECT_TOKEN = $PostHogProjectToken - POSTHOG_HOST = $PostHogHost - } - $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" + $dartDefines.POSTHOG_PROJECT_TOKEN = $PostHogProjectToken + $dartDefines.POSTHOG_HOST = $PostHogHost } + $dartDefinesPath = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-dart-defines-{0}.json" -f [guid]::NewGuid()) + Write-JsonFileUtf8 -Path $dartDefinesPath -Value $dartDefines + $flutterBuildArguments += "--dart-define-from-file=$dartDefinesPath" Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "fvm" -Arguments $flutterBuildArguments # Stage the video-export encoder into the build output before packaging. Invoke-RepoCommand -WorkingDirectory $repoRoot -Command "powershell" -Arguments @( diff --git a/scripts/common_release.ps1 b/scripts/common_release.ps1 index 496dc304..b61f45d2 100644 --- a/scripts/common_release.ps1 +++ b/scripts/common_release.ps1 @@ -79,6 +79,173 @@ function Get-VersionInfo { } } +function Get-ReleaseBranchName { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [string]$BranchName = "" + ) + + if (-not [string]::IsNullOrWhiteSpace($BranchName)) { + return $BranchName.Trim() + } + + if ($env:GITHUB_REF_TYPE -eq "branch" -and -not [string]::IsNullOrWhiteSpace($env:GITHUB_REF_NAME)) { + return $env:GITHUB_REF_NAME.Trim() + } + + $resolvedBranch = (& git -C $RepoRoot branch --show-current).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($resolvedBranch)) { + throw "Could not determine the current branch for release safety checks." + } + + return $resolvedBranch +} + +function Assert-ReleaseBranch { + param( + [Parameter(Mandatory = $true)] + [string]$RepoRoot, + [Parameter(Mandatory = $true)] + [ValidateSet("stable-desktop", "prerelease-desktop", "store", "production-backend")] + [string]$ReleaseTarget, + [string]$BranchName = "" + ) + + $resolvedBranch = Get-ReleaseBranchName -RepoRoot $RepoRoot -BranchName $BranchName + if ($ReleaseTarget -ne "prerelease-desktop" -and $resolvedBranch -ne "main") { + throw "Release target '$ReleaseTarget' is public and can only run from branch 'main'. Current branch: '$resolvedBranch'." + } + + Write-Host "Release branch check passed for '$ReleaseTarget' on '$resolvedBranch'." -ForegroundColor Green + return $resolvedBranch +} + +function Resolve-CloudBuildConfiguration { + param( + [Parameter(Mandatory = $true)] + [ValidateSet("stable", "prerelease", "store")] + [string]$ReleaseTarget, + [string]$ProductionConvexDeploymentUrl = "", + [string]$ProductionConvexClientId = "" + ) + + if ($ReleaseTarget -eq "prerelease") { + return [ordered]@{ + Environment = "development" + DeploymentUrl = "" + ClientId = "" + } + } + + $deploymentUrl = $ProductionConvexDeploymentUrl.Trim() + $clientId = $ProductionConvexClientId.Trim() + if ([string]::IsNullOrWhiteSpace($deploymentUrl) -or [string]::IsNullOrWhiteSpace($clientId)) { + throw "Production cloud configuration is missing. Set ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL and ICARUS_PRODUCTION_CONVEX_CLIENT_ID before building '$ReleaseTarget'." + } + + $parsedUrl = $null + if (-not [System.Uri]::TryCreate($deploymentUrl, [System.UriKind]::Absolute, [ref]$parsedUrl) -or + $parsedUrl.Scheme -ne "https" -or + [string]::IsNullOrWhiteSpace($parsedUrl.Host)) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be an absolute HTTPS URL." + } + + $hasCanonicalConvexOrigin = $deploymentUrl -match '^https://[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.convex\.cloud/?$' + if (-not $hasCanonicalConvexOrigin) { + throw "ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL must be a canonical https://.convex.cloud URL." + } + + if ($parsedUrl.Host -ieq "majestic-eel-413.convex.cloud" -or $clientId -eq "dev:majestic-eel-413") { + throw "Production cloud configuration cannot use the Icarus development Convex deployment." + } + + return [ordered]@{ + Environment = "production" + DeploymentUrl = $deploymentUrl + ClientId = $clientId + } +} + +function Test-PublishesStablePages { + param( + [Parameter(Mandatory = $true)] + [string]$SourceDirectory, + [Parameter()] + [string[]]$SyncPaths = @() + ) + + if ($SyncPaths.Count -gt 0) { + $stableRoots = @( + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "updates/windows/stable"), + (Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath "downloads/windows/stable") + ) + + foreach ($syncPath in $SyncPaths) { + $selectedPath = Resolve-PagesSyncPath -RootDirectory $SourceDirectory -SyncPath $syncPath + foreach ($stableRoot in $stableRoots) { + if ((Test-ReleasePathsOverlap -FirstPath $selectedPath -SecondPath $stableRoot)) { + return $true + } + } + } + return $false + } + + $stableRoots = @( + (Join-Path $SourceDirectory "updates\windows\stable"), + (Join-Path $SourceDirectory "downloads\windows\stable") + ) + return @($stableRoots | Where-Object { Test-Path -LiteralPath $_ }).Count -gt 0 +} + +function Resolve-PagesSyncPath { + param( + [Parameter(Mandatory = $true)] + [string]$RootDirectory, + [Parameter(Mandatory = $true)] + [string]$SyncPath + ) + + if ([string]::IsNullOrWhiteSpace($SyncPath)) { + throw "Pages sync paths cannot be empty. Omit SyncPaths to publish the full source directory." + } + if ([System.IO.Path]::IsPathRooted($SyncPath)) { + throw "Pages sync path '$SyncPath' must be relative to the Pages source directory." + } + + $rootPath = [System.IO.Path]::GetFullPath($RootDirectory) + $resolvedPath = [System.IO.Path]::GetFullPath((Join-Path $rootPath $SyncPath)) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $rootPrefix = if ($rootPath.EndsWith($separator)) { $rootPath } else { "$rootPath$separator" } + $isRoot = [string]::Equals($resolvedPath, $rootPath, [System.StringComparison]::OrdinalIgnoreCase) + $isChild = $resolvedPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) + if (-not $isRoot -and -not $isChild) { + throw "Pages sync path '$SyncPath' must stay within the Pages source directory." + } + + return $resolvedPath +} + +function Test-ReleasePathsOverlap { + param( + [Parameter(Mandatory = $true)] + [string]$FirstPath, + [Parameter(Mandatory = $true)] + [string]$SecondPath + ) + + $first = [System.IO.Path]::GetFullPath($FirstPath) + $second = [System.IO.Path]::GetFullPath($SecondPath) + $separator = [System.IO.Path]::DirectorySeparatorChar.ToString() + $firstPrefix = if ($first.EndsWith($separator)) { $first } else { "$first$separator" } + $secondPrefix = if ($second.EndsWith($separator)) { $second } else { "$second$separator" } + + return [string]::Equals($first, $second, [System.StringComparison]::OrdinalIgnoreCase) -or + $first.StartsWith($secondPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + $second.StartsWith($firstPrefix, [System.StringComparison]::OrdinalIgnoreCase) +} + function Get-FlutterRoot { param( [Parameter(Mandatory = $true)] diff --git a/scripts/publish_pages_branch.ps1 b/scripts/publish_pages_branch.ps1 index 366d23e0..c5c788a4 100644 --- a/scripts/publish_pages_branch.ps1 +++ b/scripts/publish_pages_branch.ps1 @@ -12,6 +12,10 @@ Set-StrictMode -Version Latest $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot $resolvedSourceDir = Resolve-RepoPath -RepoRoot $repoRoot -RelativePath $SourceDir +$publishesStable = Test-PublishesStablePages -SourceDirectory $resolvedSourceDir -SyncPaths $SyncPaths +if ($publishesStable) { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" | Out-Null +} if (-not (Test-Path $resolvedSourceDir)) { throw "Pages source directory not found at $resolvedSourceDir" @@ -80,19 +84,28 @@ try { if ($SyncPaths.Count -gt 0) { foreach ($syncPath in $SyncPaths) { - $sourcePath = Join-Path $resolvedSourceDir $syncPath - $targetPath = Join-Path $tempRoot $syncPath + $sourcePath = Resolve-PagesSyncPath -RootDirectory $resolvedSourceDir -SyncPath $syncPath + $targetPath = Resolve-PagesSyncPath -RootDirectory $tempRoot -SyncPath $syncPath + + if ([string]::Equals( + $targetPath, + [System.IO.Path]::GetFullPath($tempRoot), + [System.StringComparison]::OrdinalIgnoreCase + )) { + Copy-Item -Path (Join-Path $resolvedSourceDir "*") -Destination $tempRoot -Recurse -Force + continue + } - if (Test-Path $targetPath) { - Remove-Item -Path $targetPath -Recurse -Force + if (Test-Path -LiteralPath $targetPath) { + Remove-Item -LiteralPath $targetPath -Recurse -Force } - if (Test-Path $sourcePath) { + if (Test-Path -LiteralPath $sourcePath) { $targetParent = Split-Path -Parent $targetPath if (-not [string]::IsNullOrWhiteSpace($targetParent)) { New-Item -ItemType Directory -Force -Path $targetParent | Out-Null } - Copy-Item -Path $sourcePath -Destination $targetPath -Recurse -Force + Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Recurse -Force } } } diff --git a/scripts/release_desktop.ps1 b/scripts/release_desktop.ps1 index e128c1b6..70bbdc91 100644 --- a/scripts/release_desktop.ps1 +++ b/scripts/release_desktop.ps1 @@ -16,6 +16,8 @@ param( [string]$PagesStageRoot = "release\out\gh-pages", [string]$MetadataDir = "release\metadata", [string]$AppArchiveBaseUrl = "", + [string]$ProductionConvexDeploymentUrl = $env:ICARUS_PRODUCTION_CONVEX_DEPLOYMENT_URL, + [string]$ProductionConvexClientId = $env:ICARUS_PRODUCTION_CONVEX_CLIENT_ID, [switch]$SkipPubGet ) @@ -25,6 +27,12 @@ Set-StrictMode -Version Latest . (Join-Path $PSScriptRoot "common_release.ps1") $repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot +$releaseTarget = if ($Channel -eq "stable") { "stable-desktop" } else { "prerelease-desktop" } +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget $releaseTarget | Out-Null +Resolve-CloudBuildConfiguration ` + -ReleaseTarget $Channel ` + -ProductionConvexDeploymentUrl $ProductionConvexDeploymentUrl ` + -ProductionConvexClientId $ProductionConvexClientId | Out-Null if ([string]::IsNullOrWhiteSpace($AppArchiveBaseUrl)) { $AppArchiveBaseUrl = "https://sunkenintime.github.io/icarus/updates/windows/$Channel" @@ -65,7 +73,11 @@ $buildArgs = @( "-AppArchiveBaseUrl", $AppArchiveBaseUrl, "-InitialChangeMessage", - $ChangeMessage + $ChangeMessage, + "-ProductionConvexDeploymentUrl", + $ProductionConvexDeploymentUrl, + "-ProductionConvexClientId", + $ProductionConvexClientId ) if ($Mandatory) { diff --git a/scripts/test_online_beta_windows_upgrade.ps1 b/scripts/test_online_beta_windows_upgrade.ps1 new file mode 100644 index 00000000..72fae1ea --- /dev/null +++ b/scripts/test_online_beta_windows_upgrade.ps1 @@ -0,0 +1,475 @@ +param( + [Parameter(Mandatory = $true)] + [string]$ReleaseCandidateInstaller, + # The last stable desktop release: the build users upgrade from, and the + # one a rollback reinstalls. + [string]$PublicInstallerUrl = "https://github.com/SunkenInTime/icarus/releases/download/desktop-stable-v4.6.3%2B103/icarus-setup.exe", + [string]$PublicInstallerSha256 = "356196e515ccab61c547c065dbe8fc93003865ac90c15239d10b318a03055079", + [string]$PublicVersion = "4.6.3", + [string]$ReleaseCandidateVersion = "", + [string]$PublicFixtureUrl = "https://raw.githubusercontent.com/SunkenInTime/icarus/fddec2b0b6163cf3962863db18ab0f06ea467773/base-test.ica", + [string]$PublicFixtureSha256 = "166eb3ad31a19aca418081dc3ba949aedbf12b5049cd007fda410814a03b0b1a", + # Each launch must stay alive this long and show a responsive window in + # it. A cold runner's first launch of the public build once took more + # than 12 seconds to show its window (2026-10-01). + [int]$LaunchSeconds = 30 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($env:GITHUB_ACTIONS -ne "true") { + throw "This smoke test replaces the installed Icarus build and is restricted to disposable GitHub Actions runners." +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$releaseCandidateInstallerPath = if ([System.IO.Path]::IsPathRooted($ReleaseCandidateInstaller)) { + [System.IO.Path]::GetFullPath($ReleaseCandidateInstaller) +} else { + [System.IO.Path]::GetFullPath((Join-Path $repoRoot $ReleaseCandidateInstaller)) +} +$installedExe = $null +$supportDir = Join-Path $env:APPDATA "xyz.icarus-strats\icarus" +$strategyBox = Join-Path $supportDir "strategy_box.hive" +$testRoot = Join-Path $env:RUNNER_TEMP "icarus-online-beta-windows-smoke" +$publicInstaller = Join-Path $testRoot "icarus-setup-$PublicVersion.exe" +$fixtureFullPath = Join-Path $testRoot "base-test-v35.ica" +$supportBackup = Join-Path $testRoot "preexisting-app-support" +$evidencePath = Join-Path $testRoot "evidence.json" + +if (-not (Test-Path $releaseCandidateInstallerPath -PathType Leaf)) { + throw "Release-candidate installer not found: $releaseCandidateInstallerPath" +} +if ([string]::IsNullOrWhiteSpace($ReleaseCandidateVersion)) { + $pubspec = Get-Content (Join-Path $repoRoot "pubspec.yaml") -Raw + if ($pubspec -notmatch 'version:\s*(\d+\.\d+\.\d+)\+\d+') { + throw "Could not parse the release-candidate version from pubspec.yaml." + } + $ReleaseCandidateVersion = $Matches[1] +} + +function Install-Icarus { + param( + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$ExpectedVersion + ) + + $process = Start-Process -FilePath $InstallerPath -ArgumentList @( + "/VERYSILENT", + "/SUPPRESSMSGBOXES", + "/NORESTART", + "/SP-", + "/TASKS=`"`"" + ) -Wait -PassThru + if ($process.ExitCode -ne 0) { + throw "Installer failed with exit code $($process.ExitCode): $InstallerPath" + } + $script:installedExe = Resolve-IcarusExecutable -ExpectedVersion $ExpectedVersion + Write-Host "Resolved installed Icarus executable: $script:installedExe" +} + +function Resolve-IcarusExecutable { + param([Parameter(Mandatory = $true)][string]$ExpectedVersion) + + $uninstallRoots = @( + "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + ) + $registryCandidates = foreach ($root in $uninstallRoots) { + if (-not (Test-Path $root)) { + continue + } + Get-ChildItem $root -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | + Where-Object { + $displayNameProperty = $_.PSObject.Properties['DisplayName'] + $null -ne $displayNameProperty -and + ([string]$displayNameProperty.Value) -like "Icarus*" + } | + ForEach-Object { + $displayIconProperty = $_.PSObject.Properties['DisplayIcon'] + if ($null -ne $displayIconProperty -and + -not [string]::IsNullOrWhiteSpace($displayIconProperty.Value)) { + $displayIcon = ([string]$displayIconProperty.Value).Trim() + ($displayIcon -replace ',\d+$', '').Trim('"') + } + $installLocationProperty = $_.PSObject.Properties['InstallLocation'] + if ($null -ne $installLocationProperty -and + -not [string]::IsNullOrWhiteSpace($installLocationProperty.Value)) { + Join-Path ([string]$installLocationProperty.Value) "icarus.exe" + } + } + } + $resolved = $registryCandidates | + Where-Object { Test-Path $_ -PathType Leaf } | + ForEach-Object { Get-Item $_ } | + Where-Object { + Test-ObservedVersion -ObservedVersion (Get-ObservedVersion -ExecutablePath $_.FullName) ` + -ExpectedVersion $ExpectedVersion + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + + if ($null -eq $resolved) { + $resolved = Get-ChildItem -Path $env:LOCALAPPDATA -Filter "icarus.exe" ` + -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { + Test-ObservedVersion -ObservedVersion (Get-ObservedVersion -ExecutablePath $_.FullName) ` + -ExpectedVersion $ExpectedVersion + } | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + } + if ($null -eq $resolved) { + throw "Installer completed, but no Icarus $ExpectedVersion executable was found in the per-user registry or LocalAppData." + } + return $resolved.FullName +} + +function Get-ObservedVersion { + param([Parameter(Mandatory = $true)][string]$ExecutablePath) + + $versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($ExecutablePath) + $observed = [string]$versionInfo.ProductVersion + if ([string]::IsNullOrWhiteSpace($observed)) { + $observed = [string]$versionInfo.FileVersion + } + return $observed.Trim() +} + +function Test-ObservedVersion { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$ObservedVersion, + [Parameter(Mandatory = $true)][string]$ExpectedVersion + ) + + $expectedMatch = [System.Text.RegularExpressions.Regex]::Match( + $ExpectedVersion, + '^\d+\.\d+\.\d+$' + ) + $observedMatch = [System.Text.RegularExpressions.Regex]::Match( + $ObservedVersion, + '^(?\d+\.\d+\.\d+)(?:\.0)?(?:\+\d+)?$' + ) + return $expectedMatch.Success -and + $observedMatch.Success -and + $observedMatch.Groups['core'].Value -eq $ExpectedVersion +} + +function Assert-InstalledVersion { + param([Parameter(Mandatory = $true)][string]$ExpectedVersion) + + $observed = Get-ObservedVersion -ExecutablePath $installedExe + if (-not (Test-ObservedVersion -ObservedVersion $observed -ExpectedVersion $ExpectedVersion)) { + throw "Expected installed Icarus $ExpectedVersion, observed '$observed'." + } + return $observed +} + +function Start-And-ProveAlive { + param([string[]]$Arguments = @()) + + $process = if ($Arguments.Count -gt 0) { + Start-Process -FilePath $installedExe -ArgumentList $Arguments -PassThru + } else { + Start-Process -FilePath $installedExe -PassThru + } + $deadline = (Get-Date).AddSeconds($LaunchSeconds) + $windowReady = $false + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 500 + $process.Refresh() + if ($process.HasExited) { + throw "Icarus exited during the $LaunchSeconds-second Windows launch smoke. Exit code: $($process.ExitCode)" + } + if ($process.MainWindowHandle -ne 0 -and $process.Responding) { + $windowReady = $true + } + } + if (-not $windowReady) { + throw "Icarus stayed alive but did not expose a responsive Windows window within $LaunchSeconds seconds." + } + return $process +} + +function Stop-Icarus { + param([System.Diagnostics.Process]$Process) + + if ($null -ne $Process) { + $Process.Refresh() + if (-not $Process.HasExited) { + $closeRequested = $Process.CloseMainWindow() + if (-not $closeRequested -or -not $Process.WaitForExit(10000)) { + Stop-Process -Id $Process.Id -Force + $Process.WaitForExit(10000) | Out-Null + } + } + } + Get-Process -Name "icarus" -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue +} + +# Processes that can hold files in the support directory: Icarus itself and +# helpers started with a path inside it (for example a WebView2 user-data dir). +function Get-SupportDirProcesses { + Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | + Where-Object { + $_.Name -eq "icarus.exe" -or + ($null -ne $_.CommandLine -and $_.CommandLine.Contains($supportDir)) + } +} + +function Wait-ForSupportDirRelease { + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + $holders = @(Get-SupportDirProcesses) + if ($holders.Count -eq 0) { + return + } + $holders | ForEach-Object { + Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue + } + Start-Sleep -Milliseconds 500 + } + $names = (@(Get-SupportDirProcesses) | ForEach-Object { "$($_.Name) ($($_.ProcessId))" }) -join ", " + Write-Warning "Processes still running 30 seconds after Icarus was stopped: $names" +} + +function Remove-SupportDir { + $attempts = 10 + for ($attempt = 1; $attempt -le $attempts; $attempt++) { + if (-not (Test-Path $supportDir)) { + return + } + try { + Remove-Item -Path $supportDir -Recurse -Force -ErrorAction Stop + return + } + catch { + if ($attempt -eq $attempts) { + throw "Could not delete $supportDir after $attempts attempts: $($_.Exception.Message)" + } + Write-Host "Delete of $supportDir failed (attempt $attempt of $attempts), retrying: $($_.Exception.Message)" + Start-Sleep -Seconds 2 + } + } +} + +function Wait-ForStrategyLibrary { + $deadline = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline) { + if ((Test-Path $strategyBox -PathType Leaf) -and (Get-Item $strategyBox).Length -gt 0) { + return + } + Start-Sleep -Milliseconds 500 + } + throw "The public build did not import the legacy .ica fixture into $strategyBox" +} + +function Get-StrategyLibraryEvidence { + if (-not (Test-Path $strategyBox -PathType Leaf)) { + throw "Strategy library is missing: $strategyBox" + } + $file = Get-Item $strategyBox + if ($file.Length -le 0) { + throw "Strategy library is empty: $strategyBox" + } + return [ordered]@{ + byteSize = $file.Length + sha256 = (Get-FileHash -Path $strategyBox -Algorithm SHA256).Hash.ToLowerInvariant() + } +} + +function Invoke-LibraryProbe { + param([Parameter(Mandatory = $true)][string]$Stage) + + $probePath = Join-Path $testRoot "library-$Stage.json" + $beforeProbe = Get-StrategyLibraryEvidence + $env:ICARUS_WINDOWS_SMOKE_SUPPORT_DIR = $supportDir + $env:ICARUS_WINDOWS_SMOKE_PROBE_PATH = $probePath + $env:ICARUS_WINDOWS_SMOKE_EXPECTED_STRATEGY_NAME = "base-test-v35" + Push-Location $repoRoot + try { + & fvm flutter test test/windows_online_beta_library_probe_test.dart --reporter compact | Write-Host + if ($LASTEXITCODE -ne 0) { + throw "The $Stage Strategy library probe failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + Remove-Item Env:ICARUS_WINDOWS_SMOKE_SUPPORT_DIR -ErrorAction SilentlyContinue + Remove-Item Env:ICARUS_WINDOWS_SMOKE_PROBE_PATH -ErrorAction SilentlyContinue + Remove-Item Env:ICARUS_WINDOWS_SMOKE_EXPECTED_STRATEGY_NAME -ErrorAction SilentlyContinue + } + + if (-not (Test-Path $probePath -PathType Leaf)) { + throw "The $Stage Strategy library probe did not write $probePath" + } + $afterProbe = Get-StrategyLibraryEvidence + if ($afterProbe.byteSize -ne $beforeProbe.byteSize -or + $afterProbe.sha256 -ne $beforeProbe.sha256) { + throw "The read-only $Stage Strategy library probe changed the Hive file." + } + + $probe = Get-Content $probePath -Raw | ConvertFrom-Json + $hasher = [System.Security.Cryptography.SHA256]::Create() + try { + $invariantHash = [System.BitConverter]::ToString( + $hasher.ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes( + [string]$probe.preservationInvariants + ) + ) + ).Replace("-", "").ToLowerInvariant() + $hasher.Initialize() + $canonicalHash = [System.BitConverter]::ToString( + $hasher.ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes( + [string]$probe.canonicalCurrentState + ) + ) + ).Replace("-", "").ToLowerInvariant() + } + finally { + $hasher.Dispose() + } + $probe | Add-Member -NotePropertyName invariantSha256 -NotePropertyValue $invariantHash + $probe | Add-Member -NotePropertyName canonicalSha256 -NotePropertyValue $canonicalHash + return $probe +} + +New-Item -ItemType Directory -Force -Path $testRoot | Out-Null +$hadPreexistingSupport = Test-Path $supportDir +if ($hadPreexistingSupport) { + if (Test-Path $supportBackup) { + Remove-Item -Path $supportBackup -Recurse -Force + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $supportBackup) | Out-Null + Move-Item -Path $supportDir -Destination $supportBackup +} + +$runningProcess = $null +$testError = $null +try { + Invoke-WebRequest -Uri $PublicInstallerUrl -OutFile $publicInstaller + $observedPublicInstallerSha256 = + (Get-FileHash -Path $publicInstaller -Algorithm SHA256).Hash.ToLowerInvariant() + if ($observedPublicInstallerSha256 -ne $PublicInstallerSha256.ToLowerInvariant()) { + throw "Public installer checksum mismatch. Expected $PublicInstallerSha256, observed $observedPublicInstallerSha256." + } + Invoke-WebRequest -Uri $PublicFixtureUrl -OutFile $fixtureFullPath + $observedFixtureSha256 = + (Get-FileHash -Path $fixtureFullPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($observedFixtureSha256 -ne $PublicFixtureSha256.ToLowerInvariant()) { + throw "Historical .ica fixture checksum mismatch. Expected $PublicFixtureSha256, observed $observedFixtureSha256." + } + + Install-Icarus -InstallerPath $publicInstaller -ExpectedVersion $PublicVersion + $observedPublicInstallPath = $installedExe + $observedPublicVersion = Assert-InstalledVersion -ExpectedVersion $PublicVersion + $runningProcess = Start-And-ProveAlive -Arguments @($fixtureFullPath) + Wait-ForStrategyLibrary + Stop-Icarus -Process $runningProcess + $runningProcess = $null + $beforeUpgrade = Get-StrategyLibraryEvidence + $publicProbe = Invoke-LibraryProbe -Stage "public" + + Install-Icarus -InstallerPath $releaseCandidateInstallerPath ` + -ExpectedVersion $ReleaseCandidateVersion + $observedCandidateInstallPath = $installedExe + $observedCandidateVersion = Assert-InstalledVersion -ExpectedVersion $ReleaseCandidateVersion + $runningProcess = Start-And-ProveAlive + Stop-Icarus -Process $runningProcess + $runningProcess = $null + $afterUpgrade = Get-StrategyLibraryEvidence + $candidateProbe = Invoke-LibraryProbe -Stage "candidate" + if ($candidateProbe.invariantSha256 -ne $publicProbe.invariantSha256) { + throw "The release-candidate upgrade changed Strategy, page, or entity identity and ordering invariants." + } + if ($candidateProbe.canonicalSha256 -ne $publicProbe.canonicalSha256) { + throw "The release-candidate upgrade changed the normalized Strategy library semantics." + } + + Install-Icarus -InstallerPath $publicInstaller -ExpectedVersion $PublicVersion + $observedRollbackInstallPath = $installedExe + $observedRollbackVersion = Assert-InstalledVersion -ExpectedVersion $PublicVersion + $runningProcess = Start-And-ProveAlive + Stop-Icarus -Process $runningProcess + $runningProcess = $null + $afterRollback = Get-StrategyLibraryEvidence + $rollbackProbe = Invoke-LibraryProbe -Stage "rollback" + if ($rollbackProbe.invariantSha256 -ne $candidateProbe.invariantSha256) { + throw "Rolling back to the public build changed Strategy, page, or entity identity and ordering invariants." + } + if ($rollbackProbe.canonicalSha256 -ne $candidateProbe.canonicalSha256) { + throw "Rolling back to the public build changed the Strategy library semantics." + } + if ($afterRollback.byteSize -ne $afterUpgrade.byteSize -or + $afterRollback.sha256 -ne $afterUpgrade.sha256) { + throw "The public rollback build rewrote the release-candidate Strategy library bytes." + } + + $evidence = [ordered]@{ + publicInstallerUrl = $PublicInstallerUrl + publicInstallerSha256 = $observedPublicInstallerSha256 + publicVersion = $observedPublicVersion + publicInstallPath = $observedPublicInstallPath + releaseCandidateVersion = $observedCandidateVersion + releaseCandidateInstallPath = $observedCandidateInstallPath + rollbackVersion = $observedRollbackVersion + rollbackInstallPath = $observedRollbackInstallPath + fixture = (Split-Path -Leaf $fixtureFullPath) + fixtureSourceCommit = "fddec2b0b6163cf3962863db18ab0f06ea467773" + fixtureSha256 = $observedFixtureSha256 + strategyCount = $candidateProbe.strategyCount + strategyNames = $candidateProbe.strategyNames + publicStoredVersions = $publicProbe.storedVersions + candidateStoredVersions = $candidateProbe.storedVersions + rollbackStoredVersions = $rollbackProbe.storedVersions + preservationInvariantSha256 = $candidateProbe.invariantSha256 + normalizedLibrarySha256 = $candidateProbe.canonicalSha256 + publicLibraryBytes = $beforeUpgrade.byteSize + publicLibrarySha256 = $beforeUpgrade.sha256 + candidateLibraryBytes = $afterUpgrade.byteSize + candidateLibrarySha256 = $afterUpgrade.sha256 + rollbackLibraryBytes = $afterRollback.byteSize + rollbackLibrarySha256 = $afterRollback.sha256 + upgradePreservedLibrarySemantics = $true + rollbackPreservedLibrarySemantics = $true + rollbackPreservedCandidateBytes = $true + launchSecondsPerBuild = $LaunchSeconds + } + $evidence | ConvertTo-Json -Depth 4 | Set-Content -Path $evidencePath -Encoding utf8 + Write-Host "Windows public-upgrade and rollback smoke passed." + Write-Host "Evidence: $evidencePath" +} +catch { + $testError = $_ +} + +# Cleanup runs after the test either way. If both fail, the test's error is the +# one reported; a cleanup failure is only fatal when the test itself passed. +$cleanupError = $null +try { + Stop-Icarus -Process $runningProcess + Wait-ForSupportDirRelease + Remove-SupportDir + if ($hadPreexistingSupport -and (Test-Path $supportBackup)) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $supportDir) | Out-Null + Move-Item -Path $supportBackup -Destination $supportDir + } +} +catch { + $cleanupError = $_ +} + +if ($null -ne $testError) { + if ($null -ne $cleanupError) { + Write-Warning "Cleanup also failed: $cleanupError" + } + throw $testError +} +if ($null -ne $cleanupError) { + throw "The upgrade and rollback test passed, but cleanup failed: $cleanupError" +} diff --git a/scripts/test_release_safety.ps1 b/scripts/test_release_safety.ps1 new file mode 100644 index 00000000..db013f91 --- /dev/null +++ b/scripts/test_release_safety.ps1 @@ -0,0 +1,192 @@ +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot "common_release.ps1") + +$repoRoot = Get-RepoRoot -ScriptDirectory $PSScriptRoot + +function Assert-ThrowsContaining { + param( + [Parameter(Mandatory = $true)] + [scriptblock]$Action, + [Parameter(Mandatory = $true)] + [string]$ExpectedMessage + ) + + try { + & $Action + } + catch { + if ($_.Exception.Message -notlike "*$ExpectedMessage*") { + throw "Expected an error containing '$ExpectedMessage', got: $($_.Exception.Message)" + } + return + } + + throw "Expected an error containing '$ExpectedMessage', but the action succeeded." +} + +function Assert-TextAppearsBefore { + param( + [Parameter(Mandatory = $true)] + [string]$Text, + [Parameter(Mandatory = $true)] + [string]$First, + [Parameter(Mandatory = $true)] + [string]$Second + ) + + $firstIndex = $Text.IndexOf($First) + $secondIndex = $Text.IndexOf($Second) + if ($firstIndex -lt 0 -or $secondIndex -lt 0 -or $firstIndex -gt $secondIndex) { + throw "Expected '$First' to appear before '$Second'." + } +} + +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "production-backend" -BranchName "main" | Out-Null +Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "prerelease-desktop" -BranchName "icarus-cloud" | Out-Null +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "stable-desktop" -BranchName "icarus-cloud" +} +Assert-ThrowsContaining -ExpectedMessage "only run from branch 'main'" -Action { + Assert-ReleaseBranch -RepoRoot $repoRoot -ReleaseTarget "store" -BranchName "feature/cloud" +} + +if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths "updates/windows/stable")) { + throw "An explicit stable updater path must require the stable branch guard." +} +foreach ($stableSelection in @( + ".", + "updates/windows", + "downloads/windows", + "updates/windows/stable/4.6.1+97", + "updates/windows/prerelease/../stable" +)) { + if (-not (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $stableSelection)) { + throw "Pages selection '$stableSelection' must require the stable branch guard." + } +} +foreach ($prereleaseSelection in @( + "updates/windows/prerelease", + "downloads/windows/prerelease" +)) { + if (Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $prereleaseSelection) { + throw "Prerelease-only Pages selection '$prereleaseSelection' must remain available on feature branches." + } +} +Assert-ThrowsContaining -ExpectedMessage "must stay within the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths ".." +} +Assert-ThrowsContaining -ExpectedMessage "must be relative to the Pages source directory" -Action { + Test-PublishesStablePages -SourceDirectory $repoRoot -SyncPaths $repoRoot +} + +$pagesFixtureRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("icarus-release-safety-" + [guid]::NewGuid().ToString("N")) +try { + New-Item -ItemType Directory -Force -Path (Join-Path $pagesFixtureRoot "downloads\windows\stable") | Out-Null + if (-not (Test-PublishesStablePages -SourceDirectory $pagesFixtureRoot)) { + throw "A full-directory publish containing stable downloads must require the stable branch guard." + } +} +finally { + if (Test-Path -LiteralPath $pagesFixtureRoot) { + Remove-Item -LiteralPath $pagesFixtureRoot -Recurse -Force + } +} + +$prereleaseConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "prerelease" +if ($prereleaseConfig.Environment -ne "development") { + throw "Prerelease builds must select the development cloud environment." +} +if (-not [string]::IsNullOrWhiteSpace($prereleaseConfig.DeploymentUrl) -or + -not [string]::IsNullOrWhiteSpace($prereleaseConfig.ClientId)) { + throw "Prerelease builds must use the app's named development defaults." +} + +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" +} +Assert-ThrowsContaining -ExpectedMessage "Production cloud configuration is missing" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" +} +Assert-ThrowsContaining -ExpectedMessage "absolute HTTPS URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https:production-example" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.invalid" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.site" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "canonical https://.convex.cloud URL" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud/api" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://majestic-eel-413.convex.cloud/" ` + -ProductionConvexClientId "icarus-production" +} +Assert-ThrowsContaining -ExpectedMessage "development Convex deployment" -Action { + Resolve-CloudBuildConfiguration -ReleaseTarget "store" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "dev:majestic-eel-413" +} + +$productionConfig = Resolve-CloudBuildConfiguration -ReleaseTarget "stable" ` + -ProductionConvexDeploymentUrl "https://production-example.convex.cloud" ` + -ProductionConvexClientId "icarus-production" +if ($productionConfig.Environment -ne "production" -or + $productionConfig.DeploymentUrl -ne "https://production-example.convex.cloud" -or + $productionConfig.ClientId -ne "icarus-production") { + throw "A complete production cloud configuration was not preserved." +} + +$desktopWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-desktop.yml") -Raw +$storeWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\release-store.yml") -Raw +$productionWorkflow = Get-Content (Join-Path $repoRoot ".github\workflows\deploy-convex-production.yml") -Raw + +Assert-TextAppearsBefore -Text $desktopWorkflow -First "Run Release Preflight" -Second "Install FVM" +if ($desktopWorkflow -notmatch 'production-approval:[\s\S]*environment:\s*Production' -or + $desktopWorkflow -notmatch 'needs:\s*production-approval') { + throw "Stable desktop publishing must pass the GitHub Production environment before the build job." +} +Assert-TextAppearsBefore -Text $storeWorkflow -First "Run Store Release Preflight" -Second "Bump Version" +if ($productionWorkflow -notmatch 'environment:\s*Production') { + throw "The production Convex deployment job must use the GitHub Production environment." +} +if ($productionWorkflow -notmatch 'secrets\.CONVEX_PRODUCTION_DEPLOY_KEY') { + throw "The production Convex deployment must read CONVEX_PRODUCTION_DEPLOY_KEY." +} +if ($productionWorkflow -match 'CONVEX_PREVIEW_DEPLOY_KEY') { + throw "The production Convex deployment must never reference the preview deploy key." +} +if ($productionWorkflow -notmatch 'CONFIRMATION:\s*\$\{\{\s*inputs\.confirmation\s*\}\}' -or + $productionWorkflow -notmatch 'if \[\[ "\$CONFIRMATION" != "deploy-production" \]\]') { + throw "The production confirmation must enter Bash through the environment and remain data." +} +if ($productionWorkflow -match 'if \[\[ "\$\{\{\s*inputs\.confirmation') { + throw "The production confirmation must never be interpolated directly into Bash source." +} +Assert-TextAppearsBefore -Text $productionWorkflow -First "Check Convex Types" -Second "Deploy Convex Production Backend" +Assert-TextAppearsBefore -Text $productionWorkflow -First "Run Convex Tests" -Second "Deploy Convex Production Backend" + +$currentMetadata = Get-Content (Join-Path $repoRoot "release\metadata\4.6.1+97.json") -Raw | ConvertFrom-Json +if (@($currentMetadata.channels) -contains "stable") { + throw "The current online-beta metadata must not claim the stable channel." +} +if (@($currentMetadata.channels) -notcontains "prerelease") { + throw "The current online-beta metadata must include the prerelease channel." +} + +Write-Host "Release safety checks passed." -ForegroundColor Green diff --git a/test/ability_anchor_scaling_test.dart b/test/ability_anchor_scaling_test.dart index 16e0295b..8c0e3b20 100644 --- a/test/ability_anchor_scaling_test.dart +++ b/test/ability_anchor_scaling_test.dart @@ -15,7 +15,7 @@ import 'package:icarus/providers/strategy_settings_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: 'anchor-test', id: 'anchor-test', diff --git a/test/action_history_hydration_test.dart b/test/action_history_hydration_test.dart new file mode 100644 index 00000000..c8250088 --- /dev/null +++ b/test/action_history_hydration_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'test-strategy', + strategyName: 'Test Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() { + state = state.copyWith(isOpen: true); + } +} + +ProviderContainer _createContainer() { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_NoopStrategyProvider.new), + ], + ); + addTearDown(container.dispose); + return container; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + test('preserveHistory keeps text undo/redo working across hydration', + () async { + final container = _createContainer(); + final notifier = container.read(textProvider.notifier); + + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'before', + ]); + notifier.commitText('text-1', 'after'); + + notifier.clearAll(); + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'after', + ]); + container.read(actionProvider.notifier).reconcileHistory(); + + expect(container.read(actionProvider), hasLength(1)); + + container.read(actionProvider.notifier).undoAction(); + expect(container.read(textProvider).single.text, 'before'); + + container.read(actionProvider.notifier).redoAction(); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('non-preserved hydration clears action history', () async { + final container = _createContainer(); + final notifier = container.read(textProvider.notifier); + + notifier.fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + )..text = 'before', + ]); + notifier.commitText('text-1', 'after'); + + notifier.clearAll(); + notifier.fromHive(const []); + container.read(actionProvider.notifier).clearActionHistory(); + + expect(container.read(actionProvider), isEmpty); + }); + + test('switchSide leaves live and deleted drawings canonical', () { + final container = _createContainer(); + final notifier = container.read(drawingProvider.notifier); + + final deletedLine = Line( + id: 'deleted-line', + lineStart: const Offset(10, 20), + lineEnd: const Offset(40, 50), + color: Colors.red, + isDotted: false, + hasArrow: false, + ); + final liveLine = Line( + id: 'live-line', + lineStart: const Offset(100, 110), + lineEnd: const Offset(130, 160), + color: Colors.blue, + isDotted: false, + hasArrow: false, + ); + + notifier.fromHive([deletedLine, liveLine]); + notifier.deleteDrawing(0); + + container.read(mapProvider.notifier).switchSide(); + + final canonicalLive = + container.read(drawingProvider).elements.single as Line; + final canonicalDeleted = notifier.poppedElements.single as Line; + + expect(canonicalLive.lineStart, const Offset(100, 110)); + expect(canonicalLive.lineEnd, const Offset(130, 160)); + expect(canonicalDeleted.lineStart, const Offset(10, 20)); + expect(canonicalDeleted.lineEnd, const Offset(40, 50)); + }); + + test('switchSide leaves text canonical when measurement is missing', () { + final container = _createContainer(); + final text = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 100, + fontSize: 20, + sizeVersion: worldSizedMediaVersion, + )..text = 'One line'; + + container.read(textProvider.notifier).fromHive([text]); + container.read(mapProvider.notifier).switchSide(); + + expect(container.read(textProvider).single.position, text.position); + }); + + test('side spam leaves undo and redo snapshots canonical', () { + final container = _createContainer(); + final text = PlacedText( + id: 'history-text', + position: const Offset(310, 420), + sizeVersion: worldSizedMediaVersion, + )..text = 'before'; + container.read(textProvider.notifier).fromHive([text]); + container.read(textProvider.notifier).commitText(text.id, 'after'); + + container.read(actionProvider.notifier).undoAction(); + final redoSnapshot = + container.read(actionProvider.notifier).poppedItems.single.objectDelta!; + expect(redoSnapshot.before!.text!.position, const Offset(310, 420)); + expect(redoSnapshot.after!.text!.position, const Offset(310, 420)); + + for (var index = 0; index < 101; index++) { + container.read(mapProvider.notifier).switchSide(); + } + + final afterSpam = + container.read(actionProvider.notifier).poppedItems.single.objectDelta!; + expect(afterSpam.before!.text!.position, const Offset(310, 420)); + expect(afterSpam.after!.text!.position, const Offset(310, 420)); + container.read(actionProvider.notifier).redoAction(); + expect(container.read(textProvider).single.text, 'after'); + expect( + container.read(textProvider).single.position, + const Offset(310, 420), + ); + }); +} diff --git a/test/action_provider_bulk_clear_test.dart b/test/action_provider_bulk_clear_test.dart index ca2cbfe7..c6b07115 100644 --- a/test/action_provider_bulk_clear_test.dart +++ b/test/action_provider_bulk_clear_test.dart @@ -13,27 +13,26 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/agent_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/image_widget_size_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; -import 'package:icarus/providers/text_widget_height_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( - isSaved: true, - stratName: null, - id: 'test-strategy', + return const StrategyState( + strategyId: 'test-strategy', + strategyName: null, + source: StrategySource.local, storageDirectory: null, - activePageId: null, + isOpen: true, ); } @override void setUnsaved() { - state = state.copyWith(isSaved: false); + state = state.copyWith(isOpen: true); } } @@ -125,14 +124,16 @@ void main() { expect(container.read(utilityProvider), isEmpty); expect(container.read(agentProvider), hasLength(1)); + // The clear is one step on top of the history before it, which it + // leaves as it was; it deletes only utilities. final actions = container.read(actionProvider); - expect(actions, hasLength(2)); - expect(actions.first.group, ActionGroup.agent); + expect(actions, hasLength(3)); + expect(actions[1].group, ActionGroup.agent); expect(actions.last.group, ActionGroup.bulk); expect(actions.last.type, ActionType.bulkDeletion); expect( - actions.last.bulkSnapshot!.targetGroups, - [ActionGroup.utility], + actions.last.changes.map((change) => (change.group, change.id)), + [(ActionGroup.utility, 'utility-1')], ); }); @@ -233,16 +234,8 @@ void main() { container .read(utilityProvider.notifier) .fromHive([_buildUtility('utility-all')]); - container - .read(lineUpProvider.notifier) - .fromHive(LineUpGraph.fromLegacyLineUps([_buildLineUp('lineup-all')])); - - container - .read(imageWidgetSizeProvider.notifier) - .updateSize('image-all', const Offset(80, 60)); - container - .read(textWidgetHeightProvider.notifier) - .updateHeight('text-all', const Offset(120, 44)); + container.read(lineUpProvider.notifier).fromHive( + LineUpGraph.fromLegacyLineUps([_buildLineUp('lineup-all')])); container.read(actionProvider.notifier).clearAllAsAction(); @@ -253,14 +246,6 @@ void main() { expect(container.read(placedImageProvider).images, isEmpty); expect(container.read(utilityProvider), isEmpty); expect(container.read(lineUpProvider).links, isEmpty); - expect( - container.read(imageWidgetSizeProvider.notifier).getSize('image-all'), - Offset.zero, - ); - expect( - container.read(textWidgetHeightProvider.notifier).getOffset('text-all'), - Offset.zero, - ); expect(container.read(actionProvider), hasLength(1)); expect( container.read(actionProvider).single.type, ActionType.bulkDeletion); @@ -274,14 +259,6 @@ void main() { expect(container.read(placedImageProvider).images, hasLength(1)); expect(container.read(utilityProvider), hasLength(1)); expect(container.read(lineUpProvider).links, hasLength(1)); - expect( - container.read(imageWidgetSizeProvider.notifier).getSize('image-all'), - const Offset(80, 60), - ); - expect( - container.read(textWidgetHeightProvider.notifier).getOffset('text-all'), - const Offset(120, 44), - ); expect(container.read(actionProvider), isEmpty); }); diff --git a/test/agent_dragable_lineup_state_test.dart b/test/agent_dragable_lineup_state_test.dart index 1debb7d5..c8b7fdfd 100644 --- a/test/agent_dragable_lineup_state_test.dart +++ b/test/agent_dragable_lineup_state_test.dart @@ -70,10 +70,17 @@ Finder _opacityFinder(AgentType type) { return find.byKey(ValueKey('agent-dim-opacity-${type.name}')); } -Finder _tileFinder(AgentType type) { +Finder _agentTileFinder(AgentType type) { return find.byKey(ValueKey('agent-tile-${type.name}')); } +Finder _agentIgnorePointerFinder(AgentType type) { + return find.ancestor( + of: _opacityFinder(type), + matching: find.byType(IgnorePointer), + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -127,7 +134,7 @@ void main() { await _pumpHarness(tester, container: container); - await tester.tap(_tileFinder(AgentType.sova)); + await tester.tap(_agentTileFinder(AgentType.sova)); await tester.pumpAndSettle(); expect(container.read(abilityBarProvider)?.type, AgentType.sova); @@ -210,14 +217,20 @@ void main() { await _pumpHarness(tester, container: container); - await tester.tap(_tileFinder(AgentType.sova), warnIfMissed: false); + expect( + tester + .widget( + _agentIgnorePointerFinder(AgentType.sova).first) + .ignoring, + isTrue, + ); + await tester.tap(_agentTileFinder(AgentType.sova), warnIfMissed: false); await tester.pumpAndSettle(); expect(container.read(abilityBarProvider)?.type, AgentType.breach); }); - testWidgets('pinned origin blocks non-active drag start', - (tester) async { + testWidgets('pinned origin blocks non-active drag start', (tester) async { final container = _createContainer(); final group = LineUpGroup( id: 'breach-group', @@ -250,7 +263,7 @@ void main() { await _pumpHarness(tester, container: container); await tester.drag( - _tileFinder(AgentType.sova), + _agentTileFinder(AgentType.sova), const Offset(30, 0), warnIfMissed: false, ); diff --git a/test/agent_weapon_test.dart b/test/agent_weapon_test.dart index 6b60e2b1..845548fb 100644 --- a/test/agent_weapon_test.dart +++ b/test/agent_weapon_test.dart @@ -29,6 +29,7 @@ import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; class _TestStrategyProvider extends StrategyProvider { @override @@ -398,7 +399,7 @@ void main() { box = await Hive.openBox(HiveBoxNames.strategiesBox); _expectWeapons(box.get('firearm-strategy')!); - final notifier = container.read(strategyProvider.notifier); + final notifier = StrategyImportExportService(container); final exported = await notifier.zipStrategy( id: 'firearm-strategy', saveDir: directory, diff --git a/test/auth_callback_uri_test.dart b/test/auth_callback_uri_test.dart new file mode 100644 index 00000000..f0db4c34 --- /dev/null +++ b/test/auth_callback_uri_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/services/auth_callback_uri.dart'; + +void main() { + group('webAuthRedirectUri', () { + test('is the root of the page origin, whatever page is open', () { + expect( + webAuthRedirectUri( + Uri.parse('https://beta.icarusstrats.com/share/ICR-2345?x=1#/'), + ).toString(), + 'https://beta.icarusstrats.com/', + ); + expect( + webAuthRedirectUri(Uri.parse('https://icarus-web-a50.pages.dev/')) + .toString(), + 'https://icarus-web-a50.pages.dev/', + ); + expect( + webAuthRedirectUri(Uri.parse('http://localhost:5000/#/')).toString(), + 'http://localhost:5000/', + ); + }); + }); + + group('classifyAuthCallbackUri', () { + final webRedirect = webAuthRedirectUri( + Uri.parse('https://beta.icarusstrats.com/'), + ); + + AuthCallback web(String link) => + classifyAuthCallbackUri(Uri.parse(link), redirectUri: webRedirect); + AuthCallback native(String link) => classifyAuthCallbackUri( + Uri.parse(link), + redirectUri: nativeAuthRedirectUri, + ); + + test('a PKCE code or an error is a sign-in result', () { + for (final link in [ + 'https://beta.icarusstrats.com/?code=abc', + 'https://beta.icarusstrats.com?code=abc', + 'https://beta.icarusstrats.com/?code=abc#/', + 'https://beta.icarusstrats.com/?error=server_error' + '&error_description=Something+broke', + 'https://beta.icarusstrats.com/#error=access_denied' + '&error_description=Denied', + ]) { + expect(web(link), AuthCallback.signInResult, reason: link); + } + for (final link in [ + 'icarus://auth/callback?code=abc', + 'icarus://auth/callback?error=access_denied&error_description=Denied', + ]) { + expect(native(link), AuthCallback.signInResult, reason: link); + } + }); + + test('session tokens are rejected on web and native, even beside a code', + () { + for (final link in [ + 'https://beta.icarusstrats.com/#access_token=a&refresh_token=b' + '&expires_in=3600&token_type=bearer', + 'https://beta.icarusstrats.com/?access_token=a&refresh_token=b', + 'https://beta.icarusstrats.com/?code=abc#access_token=a', + 'https://beta.icarusstrats.com/#provider_token=a', + ]) { + expect(web(link), AuthCallback.injectedTokens, reason: link); + } + expect( + native('icarus://auth/callback#access_token=a&refresh_token=b'), + AuthCallback.injectedTokens, + ); + }); + + test('token names are matched in any case', () { + for (final link in [ + 'https://beta.icarusstrats.com/?code=c&ACCESS_TOKEN=secret', + 'https://beta.icarusstrats.com/?Refresh_Token=secret', + 'https://beta.icarusstrats.com/#Access_Token=secret&token_type=bearer', + 'https://beta.icarusstrats.com/?code=c#PROVIDER_TOKEN=secret', + ]) { + expect(web(link), AuthCallback.injectedTokens, reason: link); + } + expect( + native('icarus://auth/callback?code=c#Refresh_Token=secret'), + AuthCallback.injectedTokens, + ); + }); + + test('undecodable escapes fail closed', () { + expect( + web('https://beta.icarusstrats.com/#access_token=%E0%A4%A'), + AuthCallback.injectedTokens, + ); + expect( + web('https://beta.icarusstrats.com/?code=%E0%A4%A'), + AuthCallback.injectedTokens, + ); + }); + + test('the web origin without a sign-in result is not a callback', () { + expect(web('https://beta.icarusstrats.com/'), AuthCallback.none); + expect(web('https://beta.icarusstrats.com/#/'), AuthCallback.none); + }); + + test('a share link that carries ?code= is not a callback', () { + expect( + web('https://beta.icarusstrats.com/share?code=ICR-2345-6789-ABCD-EFGH'), + AuthCallback.none, + ); + }); + + test('anything aimed at a different origin is not a callback', () { + for (final link in [ + 'https://icarusstrats.com/?code=abc', + 'https://other.icarus-web-a50.pages.dev/?code=abc', + 'http://beta.icarusstrats.com/?code=abc', + 'https://beta.icarusstrats.com:8443/?code=abc', + 'https://icarusstrats.com/#access_token=a', + 'icarus://auth/callback?code=abc', + ]) { + expect(web(link), AuthCallback.none, reason: link); + } + expect( + native('https://beta.icarusstrats.com/?code=abc'), + AuthCallback.none, + reason: 'native builds only accept their own deep link', + ); + }); + }); + + group('withoutAuthCallbackParameters', () { + test('strips the PKCE code and keeps the page', () { + expect( + withoutAuthCallbackParameters( + Uri.parse('https://beta.icarusstrats.com/?code=abc'), + ).toString(), + 'https://beta.icarusstrats.com/', + ); + }); + + test('keeps unrelated query parameters and the Flutter route fragment', () { + expect( + withoutAuthCallbackParameters( + Uri.parse('https://beta.icarusstrats.com/?code=abc&utm=discord#/'), + ).toString(), + 'https://beta.icarusstrats.com/?utm=discord#/', + ); + }); + + test('strips errors and implicit-flow tokens from the fragment', () { + expect( + withoutAuthCallbackParameters( + Uri.parse( + 'https://beta.icarusstrats.com/?error=access_denied' + '&error_code=403&error_description=Denied' + '#access_token=a&refresh_token=b&expires_in=3600&token_type=bearer', + ), + ).toString(), + 'https://beta.icarusstrats.com/', + ); + }); + + test('scrubs an injected token link, even an undecodable one', () { + expect( + withoutAuthCallbackParameters( + Uri.parse( + 'https://beta.icarusstrats.com/#access_token=attacker' + '&refresh_token=attacker&expires_in=3600&token_type=bearer', + ), + ).toString(), + 'https://beta.icarusstrats.com/', + ); + expect( + withoutAuthCallbackParameters( + Uri.parse( + 'https://beta.icarusstrats.com/?access_token=%E0%A4%A' + '#refresh_token=%E0%A4%A', + ), + ).toString(), + 'https://beta.icarusstrats.com/', + ); + }); + + test('scrubs sign-in parameters whatever their case', () { + expect( + withoutAuthCallbackParameters( + Uri.parse( + 'https://beta.icarusstrats.com/?code=c&ACCESS_TOKEN=secret' + '&utm=discord#Refresh_Token=secret&Token_Type=bearer', + ), + ).toString(), + 'https://beta.icarusstrats.com/?utm=discord', + ); + }); + + test('keeps a non-default port', () { + expect( + withoutAuthCallbackParameters( + Uri.parse('http://localhost:5000/?code=abc'), + ).toString(), + 'http://localhost:5000/', + ); + }); + }); +} diff --git a/test/canonical_coordinates_test.dart b/test/canonical_coordinates_test.dart index c6854436..d49c2fe9 100644 --- a/test/canonical_coordinates_test.dart +++ b/test/canonical_coordinates_test.dart @@ -388,7 +388,6 @@ void main() { ImageWidget( key: const ValueKey('image-card'), id: image.id, - link: null, aspectRatio: image.aspectRatio, scale: image.scale, fileExtension: image.fileExtension, diff --git a/test/canvas_entity_hold_test.dart b/test/canvas_entity_hold_test.dart new file mode 100644 index 00000000..ed5c9020 --- /dev/null +++ b/test/canvas_entity_hold_test.dart @@ -0,0 +1,143 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; +import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +const _viewport = Size(1200, 800); + +class _TestStrategyProvider extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + isSaved: true, + stratName: null, + id: 'entity-hold-test', + storageDirectory: null, + activePageId: null, + ); + + @override + void setUnsaved() => state = state.copyWith(isSaved: false); +} + +class _TestMapProvider extends MapProvider { + @override + MapState build() => MapState(currentMap: MapValue.ascent, isAttack: true); +} + +void main() { + setUp(() { + CoordinateSystem(playAreaSize: _viewport); + CoordinateSystem.instance.setIsScreenshot(false); + }); + + testWidgets('a press on a real canvas item holds just that item', + (tester) async { + tester.view + ..physicalSize = _viewport + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_TestStrategyProvider.new), + mapProvider.overrideWith(_TestMapProvider.new), + ]); + addTearDown(container.dispose); + container.read(agentProvider.notifier).fromHive([ + PlacedAgent( + id: 'agent', type: AgentType.sova, position: const Offset(200, 200)), + ]); + container.read(lineUpProvider.notifier).fromHive(LineUpGraph( + origins: [ + LineUpOrigin( + id: 'origin', + agent: PlacedAgent( + id: 'origin-agent', + type: AgentType.jett, + position: const Offset(700, 400), + ), + ), + ], + landings: [ + LineUpLanding( + id: 'landing', + ability: PlacedAbility( + id: 'ability', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(900, 500), + ), + ), + ], + links: [ + LineUpLink(id: 'link', originId: 'origin', landingId: 'landing'), + ], + )); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + // As in main.dart: around the navigator, so overlays are inside it. + child: EditorOperationScope( + child: ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, + ), + home: Scaffold( + body: EditorCanvasRegion( + child: SizedBox.fromSize( + size: _viewport, + child: const PlacedWidgetBuilder(), + ), + ), + ), + )), + )); + await tester.pumpAndSettle(); + final agents = find.byType(AgentWidget); + final plainAgent = tester.getCenter(agents.first); + final originAgent = tester.getCenter(agents.last); + + final press = await tester.startGesture(plainAgent); + expect(container.read(editorHeldEntitiesProvider), {'agent'}); + await press.up(); + await tester.pumpAndSettle(); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + + // Empty canvas: nothing held. + final empty = await tester.startGesture(const Offset(150, 700)); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + await empty.up(); + await tester.pumpAndSettle(); + + // Dragging still reaches the item through its layer, and holds it until + // the drop commits. + final drag = await tester.startGesture(plainAgent - const Offset(2, 2), + kind: PointerDeviceKind.mouse); + await drag.moveBy(const Offset(65, 45)); + await tester.pumpAndSettle(); + expect(container.read(editorHeldEntitiesProvider), {'agent'}); + await drag.up(); + await tester.pumpAndSettle(); + expect(container.read(agentProvider).single.position, + isNot(const Offset(200, 200))); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + + // Last: pressing a lineup origin opens its panel over the canvas. + final press2 = await tester.startGesture(originAgent); + expect(container.read(editorHeldEntitiesProvider), {'origin'}); + await press2.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/test/capture_images_test.dart b/test/capture_images_test.dart new file mode 100644 index 00000000..8507ee2f --- /dev/null +++ b/test/capture_images_test.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:http/http.dart' as http; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as path; + +Uint8List _png(int red) => Uint8List.fromList( + img.encodePng( + img.fill( + img.Image(width: 4, height: 4), + color: img.ColorRgb8(red, 0, 0), + ), + ), + ); + +/// Answers only when closed, which is what aborting looks like to a caller. +class _SlowClient extends http.BaseClient { + final requested = []; + var closed = false; + final _closing = Completer(); + + @override + Future send(http.BaseRequest request) async { + requested.add(request.url.toString()); + await _closing.future; + throw http.ClientException('Client closed', request.url); + } + + @override + void close() { + closed = true; + if (!_closing.isCompleted) _closing.complete(); + } +} + +/// Like a browser client: once closed it refuses every request. Answers +/// each URL with its status after its delay in milliseconds. +class _RefusingAfterCloseClient extends http.BaseClient { + _RefusingAfterCloseClient(this.answers, this.body); + + final Map answers; + final Uint8List body; + final requested = []; + var closed = false; + + @override + Future send(http.BaseRequest request) async { + if (closed) throw http.ClientException('Client is closed', request.url); + requested.add(request.url.toString()); + final (status, delay) = answers[request.url.toString()]!; + await Future.delayed(Duration(milliseconds: delay)); + if (closed) throw http.ClientException('Request aborted', request.url); + return http.StreamedResponse(Stream.value(body), status); + } + + @override + void close() => closed = true; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final fetched = _png(10); + final pending = _png(20); + late Directory dir; + late String filePath; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('icarus_capture_images'); + filePath = path.join(dir.path, 'file.png'); + await File(filePath).writeAsBytes(_png(30)); + }); + + tearDownAll(() => dir.delete(recursive: true)); + + test('cloud URLs are fetched; everything else paints as the editor would', + () async { + final requests = <(String, String)>[]; + final images = await resolveCaptureImages( + { + 'remote': const RemoteImageUrl('https://media.example.com/remote.png'), + 'file': LocalImageFile(filePath), + 'uploading': ImageBytes(pending), + 'failed': const ImageFailed(), + }, + fetch: (imageId, url, _) async { + requests.add((imageId, url)); + return fetched; + }, + ); + addTearDown(images.release); + + expect(requests, [('remote', 'https://media.example.com/remote.png')]); + final sources = images.sources; + expect((sources['remote'] as ImageBytes).bytes, fetched); + expect((sources['file'] as LocalImageFile).path, filePath); + expect((sources['uploading'] as ImageBytes).bytes, pending); + expect(sources['failed'], isA()); + }); + + test('every image that paints is decoded and held until released', () async { + final cache = PaintingBinding.instance.imageCache; + final images = await resolveCaptureImages( + { + 'remote': const RemoteImageUrl('https://media.example.com/remote.png'), + 'file': LocalImageFile(filePath), + }, + fetch: (_, __, ___) async => fetched, + ); + + // The capture's widgets ask for the same keys and find them decoded. + final keys = [ + for (final source in images.sources.values) + await source.imageProvider!.obtainKey(ImageConfiguration.empty), + ]; + // Live, so no amount of cache pressure evicts them before the capture. + cache.clear(); + for (final key in keys) { + expect(cache.statusForKey(key).live, isTrue); + } + + images.release(); + cache.clear(); + for (final key in keys) { + expect(cache.statusForKey(key).untracked, isTrue); + } + }); + + test('cloud images download together, not one after another', () async { + final started = []; + final watch = Stopwatch()..start(); + final images = await resolveCaptureImages( + { + for (final id in ['a', 'b', 'c', 'd']) + id: RemoteImageUrl('https://media.example.com/$id.png'), + }, + fetch: (imageId, _, __) async { + started.add(imageId); + await Future.delayed(const Duration(milliseconds: 200)); + return _png(imageId.codeUnitAt(0)); + }, + ); + addTearDown(images.release); + + expect(started, ['a', 'b', 'c', 'd']); + // Four 200 ms downloads in series would take 800 ms. + expect(watch.elapsedMilliseconds, lessThan(600)); + expect(images.sources.values, everyElement(isA())); + }); + + test('a capture that fails aborts the downloads it no longer needs', + () async { + final client = _SlowClient(); + await expectLater( + http.runWithClient( + () => resolveCaptureImages( + { + 'broken': const RemoteImageUrl('https://media.example.com/x.png'), + 'slow': const RemoteImageUrl('https://media.example.com/s.png'), + }, + fetch: (imageId, url, client) async { + if (imageId == 'broken') throw Exception('offline'); + return (await client.get(Uri.parse(url))).bodyBytes; + }, + ), + () => client, + ), + throwsA(isA()), + ); + expect(client.requested, ['https://media.example.com/s.png']); + expect(client.closed, isTrue); + }); + + test('downloads and URL refreshes share a client nothing closes early', + () async { + // A browser client refuses every request once closed, and aborts the + // ones in flight. One download finishing must not close it for the + // others, or for a refresh after an expired URL. + final client = _RefusingAfterCloseClient({ + 'https://media.example.com/fast.png': (200, 10), + 'https://media.example.com/slow.png': (200, 150), + 'https://media.example.com/expired.png': (403, 20), + 'https://media.example.com/fresh.png': (200, 20), + }, _png(40)); + final images = await http.runWithClient( + () => resolveCaptureImages( + { + for (final id in ['fast', 'slow', 'expired']) + id: RemoteImageUrl('https://media.example.com/$id.png'), + }, + fetch: (imageId, url, client) => downloadCloudImageBytes( + url, + client: client, + freshUrl: () async => 'https://media.example.com/fresh.png', + ), + ), + () => client, + ); + addTearDown(images.release); + + expect(images.sources.values, everyElement(isA())); + expect(client.requested, hasLength(4)); + expect(client.closed, isTrue); + }); + + test('an image still loading stops the capture before any download', + () async { + final started = []; + await expectLater( + resolveCaptureImages( + { + 'remote': const RemoteImageUrl('https://media.example.com/r.png'), + 'loading': const ImageLoading(), + }, + fetch: (imageId, _, __) async { + started.add(imageId); + return fetched; + }, + ), + throwsA(isA()), + ); + expect(started, isEmpty); + }); + + test('an image still loading stops the capture', () async { + await expectLater( + resolveCaptureImages( + {'loading': const ImageLoading()}, + fetch: (_, __, ___) async => fetched, + ), + throwsA( + isA() + .having((error) => error.cause, 'cause', isNull) + .having( + (error) => error.userMessage, + 'userMessage', + contains('still loading'), + ), + ), + ); + }); + + test('a failed fetch stops the capture and keeps its cause', () async { + final failure = Exception('offline'); + await expectLater( + resolveCaptureImages( + {'remote': const RemoteImageUrl('https://media.example.com/r.png')}, + fetch: (_, __, ___) async => throw failure, + ), + throwsA( + isA() + .having((error) => error.cause, 'cause', same(failure)) + .having( + (error) => error.userMessage, + 'userMessage', + contains('connection'), + ), + ), + ); + }); + + test('bytes that do not decode stop the capture', () async { + await expectLater( + resolveCaptureImages( + {'remote': const RemoteImageUrl('https://media.example.com/r.png')}, + fetch: (_, __, ___) async => Uint8List.fromList([1, 2, 3, 4]), + ), + throwsA( + isA() + .having((error) => error.cause, 'cause', isNotNull), + ), + ); + }); +} diff --git a/test/cloud_build_config_test.dart b/test/cloud_build_config_test.dart new file mode 100644 index 00000000..d699c9b3 --- /dev/null +++ b/test/cloud_build_config_test.dart @@ -0,0 +1,119 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/config/cloud_build_config.dart'; + +void main() { + group('CloudBuildConfig', () { + test('debug builds default to the development environment', () { + final config = CloudBuildConfig.forBuild(isReleaseMode: false); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('release builds require an intentional environment', () { + expect( + () => CloudBuildConfig.forBuild(isReleaseMode: true), + throwsStateError, + ); + }); + + test('release builds may intentionally select development', () { + final config = CloudBuildConfig.forBuild( + isReleaseMode: true, + environment: 'development', + ); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + }); + + test('development uses the named development deployment by default', () { + final config = CloudBuildConfig.resolve(environment: 'development'); + + expect(config.environment, 'development'); + expect(config.deploymentUrl, developmentConvexDeploymentUrl); + expect(config.clientId, developmentConvexClientId); + }); + + test('development overrides must be supplied as a pair', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'development', + deploymentUrl: 'https://custom-dev.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production requires an explicit deployment URL and client ID', () { + expect( + () => CloudBuildConfig.resolve(environment: 'production'), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + ), + throwsStateError, + ); + }); + + test('production rejects the development deployment', () { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: '$developmentConvexDeploymentUrl/', + clientId: 'icarus-production', + ), + throwsStateError, + ); + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: developmentConvexClientId, + ), + throwsStateError, + ); + }); + + test('production requires a canonical Convex deployment URL', () { + for (final invalidUrl in [ + 'https://production-example.invalid', + 'https://production-example.convex.site', + 'https://production-example.convex.cloud.example.com', + 'https://production-example.convex.cloud/api', + ]) { + expect( + () => CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: invalidUrl, + clientId: 'icarus-production', + ), + throwsStateError, + reason: invalidUrl, + ); + } + }); + + test('production accepts a complete non-development configuration', () { + final config = CloudBuildConfig.resolve( + environment: 'production', + deploymentUrl: 'https://production-example.convex.cloud', + clientId: 'icarus-production', + ); + + expect(config.environment, 'production'); + expect(config.deploymentUrl, 'https://production-example.convex.cloud'); + expect(config.clientId, 'icarus-production'); + }); + + test('unknown environments fail closed', () { + expect( + () => CloudBuildConfig.resolve(environment: 'staging'), + throwsStateError, + ); + }); + }); +} diff --git a/test/cloud_lineup_rows_test.dart b/test/cloud_lineup_rows_test.dart new file mode 100644 index 00000000..24c99dc1 --- /dev/null +++ b/test/cloud_lineup_rows_test.dart @@ -0,0 +1,421 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; + +LineUpOrigin _origin(String id, {Offset position = const Offset(10, 20)}) => + LineUpOrigin( + id: id, + agent: PlacedAgent( + id: 'agent-$id', + type: AgentType.sova, + position: position, + lineUpID: id, + ), + ); + +LineUpLanding _landing(String id, {Offset position = const Offset(30, 40)}) => + LineUpLanding( + id: id, + ability: PlacedAbility( + id: 'ability-$id', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: position, + lineUpID: id, + ), + ); + +LineUpLink _link(String id, String originId, String landingId) => + LineUpLink(id: id, originId: originId, landingId: landingId, name: id); + +/// One group: two origins into landing `shared` (fan-in), and a second +/// lineup out of origin a (fan-out). And a second group, k9 alone. +LineUpGraph _page() => LineUpGraph( + origins: [_origin('a'), _origin('b'), _origin('z')], + landings: [_landing('shared'), _landing('other'), _landing('far')], + links: [ + LineUpLink( + id: 'k1', + originId: 'a', + landingId: 'shared', + name: 'A', + notes: 'jump throw', + images: [SimpleImageData(id: 'img-1', fileExtension: '.png')], + ), + _link('k2', 'b', 'shared'), + _link('k9', 'z', 'far'), + _link('k3', 'a', 'other'), + ], + ); + +String _json(LineUpGraph graph) => jsonEncode(graph.toJson()); + +/// [rows] as canonical JSON, to compare them exactly. +String _canonical(Iterable rows) => + canonicalCloudJsonEncode(jsonDecode(jsonEncode([ + for (final row in rows) + {'publicId': row.publicId, 'payload': row.payload}, + ]))); + +Map _data(CloudLineupRow row) => + Map.from(row.payload['data'] as Map); + +List _ids(Object? entries) => [ + for (final entry in entries as List) (entry as Map)['id'] as String, + ]; + +void main() { + test('lineups connected through a spot share one row with their spots', () { + final written = cloudLineupRows(_page()); + + expect(written.rows.map((row) => row.publicId), ['k1', 'k9']); + final group = written.rows.first; + expect(group.payload['kind'], cloudLineupsPayloadKind); + expect(group.payload['payloadVersion'], currentCloudPayloadVersion); + final data = _data(group); + expect(data['id'], 'k1'); + expect(_ids(data['origins']), ['a', 'b']); + expect(_ids(data['landings']), ['shared', 'other']); + expect(_ids(data['links']), ['k1', 'k2', 'k3']); + expect( + (data['links'] as List).first, + jsonDecode(jsonEncode(_page().links.first.toJson())), + reason: 'a lineup is stored as the canvas has it', + ); + expect(_ids(_data(written.rows.last)['links']), ['k9']); + expect(written.groupOf, { + for (final id in ['a', 'b', 'shared', 'other', 'k1', 'k2', 'k3']) + id: 'k1', + for (final id in ['z', 'far', 'k9']) id: 'k9', + }); + }); + + test('rows draw back the page exactly, and write back the same rows', () { + final written = cloudLineupRows(_page()); + final read = lineUpGraphFromCloudRows(written.rows); + + expect(read.groupOf, written.groupOf); + final rewritten = cloudLineupRows(read.graph, groupOf: read.groupOf); + expect(_canonical(rewritten.rows), _canonical(written.rows)); + // Graph order is rows' order: the round trip keeps every lineup, spot + // and field. + expect( + _json(read.graph), + _json(LineUpGraph( + origins: [_origin('a'), _origin('b'), _origin('z')], + landings: [_landing('shared'), _landing('other'), _landing('far')], + links: [_page().links[0], _page().links[1], _page().links[3]] + + [_page().links[2]], + )), + ); + }); + + test('a group keeps its id when the lineup it was named after goes', () { + final read = lineUpGraphFromCloudRows(cloudLineupRows(_page()).rows); + final withoutK1 = LineUpGraph( + origins: read.graph.origins, + landings: read.graph.landings, + links: [ + for (final link in read.graph.links) + if (link.id != 'k1') link, + ], + ); + + final rows = cloudLineupRows(withoutK1, groupOf: read.groupOf).rows; + expect(rows.map((row) => row.publicId), ['k1', 'k9']); + expect(_ids(_data(rows.first)['links']), ['k2', 'k3']); + }); + + test('a group never splits: halves stay in its row', () { + // k1 (a -> shared) and k3 (b -> l2) connect only through k2 + // (b -> shared). + final graph = LineUpGraph( + origins: [_origin('a'), _origin('b')], + landings: [_landing('shared'), _landing('l2')], + links: [ + _link('k1', 'a', 'shared'), + _link('k2', 'b', 'shared'), + _link('k3', 'b', 'l2'), + ], + ); + final read = lineUpGraphFromCloudRows(cloudLineupRows(graph).rows); + + // Deleting k2 leaves {k1} and {k3} unconnected. + final split = LineUpGraph( + origins: graph.origins, + landings: graph.landings, + links: [graph.links[0], graph.links[2]], + ); + final rows = cloudLineupRows(split, groupOf: read.groupOf).rows; + expect(rows.map((row) => row.publicId), ['k1']); + expect(_ids(_data(rows.single)['links']), ['k1', 'k3']); + + // Without what the rows said, the halves would be two new groups. + expect( + cloudLineupRows(split).rows.map((row) => row.publicId), + ['k1', 'k3'], + ); + }); + + test('a new lineup on a spot joins that spot\'s group', () { + final read = lineUpGraphFromCloudRows(cloudLineupRows(_page()).rows); + final added = LineUpGraph( + origins: [...read.graph.origins, _origin('c')], + landings: read.graph.landings, + links: [...read.graph.links, _link('k0', 'c', 'far')], + ); + + final written = cloudLineupRows(added, groupOf: read.groupOf); + expect(written.rows.map((row) => row.publicId), ['k1', 'k9']); + expect(_ids(_data(written.rows.last)['links']), ['k9', 'k0']); + expect(written.groupOf['k0'], 'k9'); + expect(written.groupOf['c'], 'k9'); + }); + + test( + 'a lineup joining two groups goes to one, carrying the other\'s spot ' + '(a write the server refuses), and neither row goes', () { + final read = lineUpGraphFromCloudRows(cloudLineupRows(_page()).rows); + final bridged = LineUpGraph( + origins: read.graph.origins, + landings: read.graph.landings, + links: [...read.graph.links, _link('k5', 'z', 'shared')], + ); + + final written = cloudLineupRows(bridged, groupOf: read.groupOf); + // k5 joins the smaller group, k1, and carries origin z with it; k9's + // row keeps z too, and nothing is deleted. + expect(written.rows.map((row) => row.publicId), ['k1', 'k9']); + expect(_ids(_data(written.rows.first)['links']), ['k1', 'k2', 'k3', 'k5']); + expect(_ids(_data(written.rows.first)['origins']), ['a', 'b', 'z']); + expect(_ids(_data(written.rows.last)['links']), ['k9']); + expect(_ids(_data(written.rows.last)['origins']), ['z']); + expect(written.groupOf['z'], 'k9', reason: 'a spot stays in its group'); + expect(written.groupOf['k5'], 'k1'); + }); + + test('a lineup two rows name is drawn from the first, and kept there', () { + // The server keeps rows from overlapping; should two name k9 anyway, + // the reader draws one k9, from the first. + CloudLineupRow row(String id, LineUpGraph graph) => CloudLineupRow( + publicId: id, + payload: cloudLineupsPayload({ + 'id': id, + 'origins': [for (final origin in graph.origins) origin.toJson()], + 'landings': [ + for (final landing in graph.landings) landing.toJson(), + ], + 'links': [for (final link in graph.links) link.toJson()], + }), + ); + final merged = row( + 'k1', + LineUpGraph( + origins: [_origin('a'), _origin('z')], + landings: [_landing('shared'), _landing('far')], + links: [ + _link('k1', 'a', 'shared'), + _link('k9', 'z', 'far').copyWith(notes: 'old'), + ], + ), + ); + final teammates = row( + 'k9', + LineUpGraph( + origins: [_origin('z')], + landings: [_landing('far')], + links: [_link('k9', 'z', 'far').copyWith(notes: 'newer')], + ), + ); + + final read = lineUpGraphFromCloudRows([merged, teammates]); + expect(read.graph.links.map((link) => link.id), ['k1', 'k9']); + expect(read.graph.links.last.notes, 'old'); + expect(read.groupOf['k9'], 'k1'); + expect( + cloudLineupRows(read.graph, groupOf: read.groupOf) + .rows + .map((row) => row.publicId), + ['k1'], + ); + }); + + test('a new group never takes a group id already in use', () { + final graph = LineUpGraph( + origins: [_origin('a')], + landings: [_landing('l')], + links: [_link('k1', 'a', 'l'), _link('k2', 'a', 'l')], + ); + + expect( + cloudLineupRows(graph, takenGroupIds: {'k1'}).rows.single.publicId, + 'k2', + ); + // A group id remembered for a lineup elsewhere is in use too. + expect( + cloudLineupRows(graph, groupOf: {'gone': 'k1'}).rows.single.publicId, + 'k2', + ); + // Every lineup id taken: a fresh id. + final fresh = cloudLineupRows(graph, takenGroupIds: {'k1', 'k2'}); + expect(fresh.rows.single.publicId, isNot(anyOf('k1', 'k2'))); + expect(fresh.groupOf['k1'], fresh.rows.single.publicId); + }); + + test('a lineup missing a spot, and a spot no lineup uses, are not written', + () { + final rows = cloudLineupRows(LineUpGraph( + origins: [_origin('a'), _origin('lonely')], + landings: [_landing('l')], + links: [ + _link('k1', 'a', 'l'), + _link('dangling', 'a', 'missing'), + ], + )).rows; + + expect(rows.map((row) => row.publicId), ['k1']); + expect(_ids(_data(rows.single)['origins']), ['a']); + expect(_ids(_data(rows.single)['links']), ['k1']); + }); + + test('the first row naming a spot or lineup draws it', () { + // Two clients put lineups on spot `shared` into different groups. + final first = cloudLineupRows(LineUpGraph( + origins: [_origin('a')], + landings: [_landing('shared')], + links: [_link('k1', 'a', 'shared')], + )).rows.single; + final second = cloudLineupRows(LineUpGraph( + origins: [_origin('b')], + landings: [_landing('shared', position: const Offset(99, 99))], + links: [_link('k2', 'b', 'shared')], + )).rows.single; + + final read = lineUpGraphFromCloudRows([first, second]); + expect(read.graph.landings.map((l) => l.id), ['shared']); + expect(read.graph.landings.single.ability.position, const Offset(30, 40)); + expect(read.graph.links.map((l) => l.landingId), ['shared', 'shared']); + expect(read.groupOf['shared'], 'k1'); + + // Written back, both rows stay, each carrying the spot as drawn: no + // lineup moves rows and no row is deleted. + final rows = cloudLineupRows(read.graph, groupOf: read.groupOf).rows; + expect(rows.map((row) => row.publicId), ['k1', 'k2']); + expect(_ids(_data(rows.last)['links']), ['k2']); + expect(_ids(_data(rows.last)['landings']), ['shared']); + }); + + test('a row of any other shape fails loudly, naming the row', () { + final valid = cloudLineupRows(_page()).rows.first.payload; + final data = Map.from(valid['data'] as Map); + final broken = { + // Protocol 4's graph rows, the legacy group, and the per-lineup row + // this replaced are not groups. + 'lineupLink': {...valid, 'kind': 'lineupLink'}, + 'lineupGroup': {...valid, 'kind': 'lineupGroup'}, + 'lineup': {...valid, 'kind': 'lineup'}, + 'no origins': { + ...valid, + 'data': {...data}..remove('origins'), + }, + 'no links': { + ...valid, + 'data': {...data}..remove('links'), + }, + 'a lineup aimed outside the row': { + ...valid, + 'data': { + ...data, + 'landings': [ + for (final landing in data['landings'] as List) + if ((landing as Map)['id'] != 'other') landing, + ], + }, + }, + 'not objects': { + ...valid, + 'data': { + ...data, + 'origins': ['a'], + }, + }, + }; + for (final MapEntry(key: reason, value: payload) in broken.entries) { + expect( + () => lineUpGraphFromCloudRows([ + CloudLineupRow(publicId: 'k1', payload: payload), + ]), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('Cloud lineup group k1 could not be read'), + )), + reason: reason, + ); + } + expect( + () => lineUpGraphFromCloudRows([ + CloudLineupRow(publicId: 'someone-else', payload: valid), + ]), + throwsA(isA().having( + (error) => error.message, + 'message', + contains('Cloud lineup group someone-else could not be read'), + )), + reason: 'a row keyed apart from its group', + ); + }); + + test('ops in a cloud format before lineup group rows are retired', () { + final payload = cloudLineupRows(_page()).rows.first.payload; + expect( + isRetiredCloudLineupOp(LineupAddOp( + opId: 'op', + lineupPublicId: 'k1', + pagePublicId: 'p', + payload: payload, + sortIndex: 0, + )), + isFalse, + ); + for (final kind in ['lineupLink', 'lineup']) { + expect( + isRetiredCloudLineupOp(LineupAddOp( + opId: 'op', + lineupPublicId: 'k1', + pagePublicId: 'p', + payload: {...payload, 'kind': kind}, + sortIndex: 0, + )), + isTrue, + reason: kind, + ); + } + for (final key in ['lineupOrigin:a', 'lineupLanding:l', 'lineupLink:k']) { + expect( + isRetiredCloudLineupOp(LineupDeleteOp( + opId: 'op', + lineupPublicId: key, + pagePublicId: 'p', + expectedLineupRevision: 1, + )), + isTrue, + reason: key, + ); + } + expect( + isRetiredCloudLineupOp(const ElementDeleteOp( + opId: 'op', + elementPublicId: 'lineupLink:not-a-lineup', + pagePublicId: 'p', + expectedElementRevision: 1, + )), + isFalse, + ); + }); +} diff --git a/test/cloud_media_upload_queue_provider_test.dart b/test/cloud_media_upload_queue_provider_test.dart new file mode 100644 index 00000000..d5dfba0d --- /dev/null +++ b/test/cloud_media_upload_queue_provider_test.dart @@ -0,0 +1,1485 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/convex_error_codes.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} + +class _CloudReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _DisabledCloudCollabMode extends CloudCollabModeNotifier { + @override + CloudCollabModeState build() => const CloudCollabModeState( + featureFlagEnabled: false, + forceLocalFallback: false, + ); +} + +class _EnabledCloudCollabMode extends CloudCollabModeNotifier { + @override + CloudCollabModeState build() => const CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); +} + +class _ActiveCloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.cloud, + isOpen: true, + ); +} + +class _NoOpenStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState(); +} + +class _SettledOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + clientId: 'client-a', + durableLoaded: true, + ); +} + +class _MutableMediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + void replaceJobs(List jobs) { + state = CloudMediaUploadQueueState(jobs: jobs, isProcessing: false); + } +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _GoneUploadRepository implements ConvexStrategyRepository { + final List attemptedUploadIds = []; + @override + Future completeImageUpload({ + required String strategyPublicId, + required String assetPublicId, + String? provider, + String? uploadId, + String? objectKey, + String? storageId, + String? etag, + String? mimeType, + String? fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + attemptedUploadIds.add(uploadId); + throw const ConvexFunctionException( + code: ConvexErrorCode.uploadIntentNotFound, + rawCode: 'UPLOAD_INTENT_NOT_FOUND', + message: 'Upload intent not found.', + ); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// completeImageUpload fails with NOT_FOUND; the shell check says whether +/// the strategy itself is gone. +class _NotFoundRepository implements ConvexStrategyRepository { + _NotFoundRepository({required this.strategyDeleted}); + + final bool strategyDeleted; + int completeCalls = 0; + final List shellChecks = []; + + @override + Future completeImageUpload({ + required String strategyPublicId, + required String assetPublicId, + String? provider, + String? uploadId, + String? objectKey, + String? storageId, + String? etag, + String? mimeType, + String? fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + completeCalls += 1; + throw ConvexFunctionException( + code: ConvexErrorCode.notFound, + rawCode: 'NOT_FOUND', + message: strategyDeleted + ? 'Strategy not found: $strategyPublicId' + : 'Uploaded image not found: $objectKey', + ); + } + + @override + Future strategyIsDeleted(String strategyPublicId) async { + shellChecks.add(strategyPublicId); + return strategyDeleted; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FailingBatchStore extends MemoryDurableCloudMediaOutboxStore { + bool failBatch = false; + bool failRemove = false; + + @override + Future remove(CloudMediaUploadJob job) async { + if (failRemove) throw StateError('delete failed'); + await super.remove(job); + } + + @override + Future putAll(Iterable jobs) async { + if (failBatch) { + throw StateError('batch write failed'); + } + await super.putAll(jobs); + } +} + +ProviderContainer _container( + MemoryDurableCloudMediaOutboxStore store, { + MemoryDurableStrategyOutboxStore? strategyStore, + StrategyOpQueueState? opQueueState, + bool strategyOpen = true, + bool cloudReady = false, + bool cloudEnabled = false, + String? accountId = 'account-a', + CloudMediaReferenceLoader? referenceLoader, + ConvexStrategyRepository? repository, +}) { + final resolvedOpQueueState = opQueueState ?? + (strategyOpen + ? const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + clientId: 'client-a', + durableLoaded: true, + ) + : const StrategyOpQueueState(durableLoaded: true)); + return ProviderContainer( + overrides: [ + durableCloudMediaOutboxStoreProvider.overrideWithValue(store), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + durableStrategyOutboxStoreProvider.overrideWithValue( + strategyStore ?? MemoryDurableStrategyOutboxStore(), + ), + authProvider.overrideWith( + cloudReady ? _CloudReadyAuthProvider.new : _SignedOutAuthProvider.new, + ), + cloudMediaAccountIdProvider.overrideWithValue(accountId), + cloudCollabModeProvider.overrideWith( + cloudEnabled + ? _EnabledCloudCollabMode.new + : _DisabledCloudCollabMode.new, + ), + convexConnectionSnapshotProvider.overrideWithValue(cloudReady), + convexConnectionProvider.overrideWith( + (ref) => Stream.value(cloudReady), + ), + strategyProvider.overrideWith( + strategyOpen ? _ActiveCloudStrategy.new : _NoOpenStrategy.new, + ), + strategyOpQueueProvider.overrideWith( + () => _FixedOpQueue(resolvedOpQueueState), + ), + if (referenceLoader != null) + cloudMediaReferenceLoaderProvider.overrideWithValue( + referenceLoader, + ), + ], + ); +} + +DurableOutboxRecord _durableRecord(StrategyOp op) { + final entityKey = EntitySyncKey.forStrategyOp(op)!; + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: entityKey, + pending: PendingOp(op: op, clientId: 'client-a'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026, 9, 3), + updatedAt: DateTime.utc(2026, 9, 3), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('processing while signed out finds no job and does not throw', () async { + // Signed out, the queue's job list is the shared `const []`; sorting it + // in place threw on every web page load. + final container = _container( + MemoryDurableCloudMediaOutboxStore(), + accountId: null, + strategyOpen: false, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + await Future.delayed(Duration.zero); + + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(container.read(cloudMediaUploadQueueProvider).isProcessing, isFalse); + }); + + test('media durability recovers after a successful retry', () async { + final store = _FailingBatchStore()..failBatch = true; + final container = _container(store); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final images = [SimpleImageData(id: 'retry-image', fileExtension: '.png')]; + await expectLater( + queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: images, + ), + throwsStateError); + store.failBatch = false; + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', images: images); + expect(store.load().issues, isEmpty); + expect(store.load().jobs, hasLength(1)); + await queue.clearJobsForStrategy('strategy-a'); + expect(store.load().jobs, isEmpty); + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + + test('removed upload intent returns to durable upload recovery', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put(CloudMediaUploadJob( + jobId: 'image-a', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'image-a', + fileExtension: '.png', + mimeType: 'image/png', + provider: 'r2', + uploadId: 'swept-upload', + objectKey: 'old/image.png', + state: CloudMediaJobState.pendingAttach, + attempts: 1, + updatedAt: DateTime.now().subtract(const Duration(days: 2)), + )); + final repository = _GoneUploadRepository(); + final container = _container(store, + cloudReady: true, cloudEnabled: true, repository: repository); + addTearDown(container.dispose); + container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(const Duration(milliseconds: 100)); + expect(repository.attemptedUploadIds, ['swept-upload']); + final restored = store.load().jobs.single; + expect(restored.uploadId, isNull); + expect(restored.objectKey, isNull); + expect(restored.hasUploadedRemoteObject, isFalse); + expect(restored.referenceDurable, isTrue); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .hasUploadedRemoteObject, + isFalse, + reason: + 'A permanently deleted server intent must not be retried as if the object still exists.'); + }); + + group('NOT_FOUND while attaching an image', () { + CloudMediaUploadJob pendingAttach(String id) => CloudMediaUploadJob( + jobId: id, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: id, + fileExtension: '.png', + mimeType: 'image/png', + provider: 'r2', + uploadId: 'upload-$id', + objectKey: 'strategies/strategy-a/$id.png', + state: CloudMediaJobState.pendingAttach, + attempts: 1, + updatedAt: DateTime.now().subtract(const Duration(days: 2)), + ); + + test('drops the jobs of a strategy the server no longer has', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put(pendingAttach('image-a')); + await store.put(pendingAttach('image-b')); + final repository = _NotFoundRepository(strategyDeleted: true); + final container = _container(store, + cloudReady: true, cloudEnabled: true, repository: repository); + addTearDown(container.dispose); + + container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(repository.shellChecks, ['strategy-a']); + expect(repository.completeCalls, 1, reason: 'never retried'); + expect(store.load().jobs, isEmpty); + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + }); + + test('keeps retrying when the strategy still exists', () async { + // e.g. the uploaded image went missing from storage. + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put(pendingAttach('image-a')); + final repository = _NotFoundRepository(strategyDeleted: false); + final container = _container(store, + cloudReady: true, cloudEnabled: true, repository: repository); + addTearDown(container.dispose); + + container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(repository.shellChecks, ['strategy-a']); + final kept = store.load().jobs.single; + expect(kept.state, CloudMediaJobState.failed); + }); + }); + + test('a successful write cannot clear another media key failure', () async { + final store = _FailingBatchStore()..failBatch = true; + final container = _container(store); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final failed = [SimpleImageData(id: 'failed', fileExtension: '.png')]; + await expectLater( + queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: failed, + ), + throwsStateError); + store.failBatch = false; + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [SimpleImageData(id: 'other', fileExtension: '.png')], + ); + expect(container.read(cloudMediaUploadQueueProvider).outboxIsReliable, + isFalse); + await queue.enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: failed, + ); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + + for (final restage in [false, true]) { + test('server reads preserve new references and restaged=$restage media', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final ops = MemoryDurableStrategyOutboxStore(); + final job = CloudMediaUploadJob( + jobId: 'racing-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'racing-image', + fileExtension: '.png', + mimeType: 'image/png', + referenceDurable: false, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 4), + ); + await store.put(job); + final snapshot = Completer?>(); + final started = Completer(); + final container = _container( + store, + strategyStore: ops, + cloudReady: true, + referenceLoader: (_, __) { + if (!started.isCompleted) started.complete(); + return snapshot.future; + }, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await started.future; + if (restage) { + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'racing-image', + fileExtension: '.jpg', + width: 42, + ); + } + await ops.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: const EntitySyncKey.element('page-a', 'racing-image'), + pending: const PendingOp( + clientId: 'client-a', + op: ElementAddOp( + opId: 'new-reference', + elementPublicId: 'racing-image', + pagePublicId: 'page-a', + sortIndex: 0, + payload: {'id': 'racing-image'}, + ), + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + )); + snapshot.complete({}); + await queue.retryNow(); + expect(store.load().jobs.single.referenceDurable, isTrue); + expect(store.load().jobs.single.width, restage ? 42 : null); + expect(store.load().jobs.single.fileExtension, restage ? '.jpg' : '.png'); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .assetPublicId, + 'racing-image'); + }); + } + + test('an orphan removal can retry and clear its durability error', () async { + final store = _FailingBatchStore(); + await store.put(CloudMediaUploadJob( + jobId: 'orphan', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'orphan', + fileExtension: '.png', + mimeType: 'image/png', + referenceDurable: false, + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 4), + )); + var snapshotAvailable = false; + final container = _container( + store, + cloudReady: true, + referenceLoader: (_, __) async { + if (!snapshotAvailable) throw StateError('offline'); + return {}; + }, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await Future.delayed(Duration.zero); + snapshotAvailable = true; + store.failRemove = true; + await expectLater(queue.retryNow(), throwsStateError); + expect(container.read(cloudMediaUploadQueueProvider).outboxIsReliable, + isFalse); + + store.failRemove = false; + await queue.retryNow(); + expect(store.load().jobs, isEmpty); + expect( + container.read(cloudMediaUploadQueueProvider).outboxIsReliable, isTrue); + }); + + test('restart restores unfinished lineup media from the durable outbox', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [ + SimpleImageData(id: 'lineup-image', fileExtension: '.png'), + ], + ); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final state = restarted.read(cloudMediaUploadQueueProvider); + + expect(state.outboxIsReliable, isTrue); + expect(state.jobs, hasLength(1)); + expect(state.jobs.single.assetPublicId, 'lineup-image'); + expect(state.jobs.single.strategyPublicId, 'strategy-a'); + expect(state.jobs.single.fileExtension, '.png'); + expect(state.jobs.single.referenceDurable, isFalse); + }); + + test('account B cannot restore or reconcile account A media', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final accountA = _container(store, accountId: 'account-a'); + await accountA + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'account-a-image', + fileExtension: '.png', + ); + accountA.dispose(); + + var snapshotReads = 0; + final accountB = _container( + store, + accountId: 'account-b', + cloudReady: true, + referenceLoader: (_, __) async { + snapshotReads += 1; + return {}; + }, + ); + addTearDown(accountB.dispose); + final queue = accountB.read(cloudMediaUploadQueueProvider.notifier); + + await queue.retryNow(ignoreBackoff: true); + + expect(accountB.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(snapshotReads, 0); + final preserved = store.load().jobs.single; + expect(preserved.accountId, 'account-a'); + expect(preserved.assetPublicId, 'account-a-image'); + expect(preserved.referenceDurable, isFalse); + }); + + test('enqueue fails before writing when no account owns the job', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container(store, accountId: null); + addTearDown(container.dispose); + + await expectLater( + container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'unowned-image', + fileExtension: '.png', + ), + throwsA(isA()), + ); + + expect(store.values, isEmpty); + }); + + test('account-scoped keys isolate identical asset IDs and removal', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + CloudMediaUploadJob job(String accountId) => CloudMediaUploadJob( + jobId: 'shared-image', + accountId: accountId, + strategyPublicId: 'strategy-a', + assetPublicId: 'shared-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ); + final accountAJob = job('account-a'); + final accountBJob = job('account-b'); + + await store.putAll([accountAJob, accountBJob]); + expect( + store.values.keys, + containsAll(['account-a|shared-image', 'account-b|shared-image']), + ); + + await store.remove(accountAJob); + + final remaining = store.load().jobs.single; + expect(remaining.accountId, 'account-b'); + expect(remaining.assetPublicId, 'shared-image'); + }); + + test('restart keeps an interrupted placement staged and non-runnable', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'interrupted-image', + fileExtension: '.png', + ); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + await Future.delayed(const Duration(milliseconds: 20)); + final job = restarted.read(cloudMediaUploadQueueProvider).jobs.single; + + expect(job.assetPublicId, 'interrupted-image'); + expect(job.referenceDurable, isFalse); + expect(job.attempts, 0); + expect(restarted.read(cloudMediaUploadQueueProvider).isProcessing, isFalse); + }); + + test('restart restores an image job without visiting its page', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final first = _container(store); + + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalBytes( + strategyPublicId: 'strategy-a', + assetPublicId: 'unvisited-page-image', + fileExtension: '.webp', + width: 640, + height: 360, + ); + await Future.delayed(const Duration(milliseconds: 20)); + first.dispose(); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final restored = restarted + .read(cloudMediaUploadQueueProvider) + .jobsForStrategy('strategy-a'); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(restored, hasLength(1)); + expect(restored.single.assetPublicId, 'unvisited-page-image'); + expect(restored.single.width, 640); + expect(restored.single.height, 360); + expect(restored.single.referenceDurable, isFalse); + }); + + test('local-file job waits for a durable strategy reference', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container( + store, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + cloudEnabled: true, + referenceLoader: (_, __) async => {}, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalBytes( + strategyPublicId: 'strategy-a', + assetPublicId: 'not-admitted-image', + fileExtension: '.png', + ); + await Future.delayed(const Duration(milliseconds: 30)); + + final job = container.read(cloudMediaUploadQueueProvider).jobs.single; + expect(job.referenceDurable, isFalse); + expect(job.attempts, 0); + expect(container.read(cloudMediaUploadQueueProvider).isProcessing, isFalse); + }); + + test('blocked uploads yield and keep their durable job pending', () async { + final store = MemoryDurableCloudMediaOutboxStore(); + final container = _container(store); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalBytes( + strategyPublicId: 'strategy-a', + assetPublicId: 'offline-image', + fileExtension: '.jpg', + ); + await Future.delayed(const Duration(milliseconds: 20)); + + final state = container.read(cloudMediaUploadQueueProvider); + expect(state.isProcessing, isFalse); + expect(state.jobs.single.attempts, 0); + expect(store.values, contains('account-a|offline-image')); + }); + + test('lineup staging is atomic when the durable batch write fails', () async { + final store = _FailingBatchStore(); + final container = _container(store); + addTearDown(container.dispose); + await container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueJobForLocalBytes( + strategyPublicId: 'strategy-a', + assetPublicId: 'existing-image', + fileExtension: '.png', + ); + await Future.delayed(const Duration(milliseconds: 20)); + store.failBatch = true; + + await expectLater( + container + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs( + strategyPublicId: 'strategy-a', + images: [ + SimpleImageData(id: 'lineup-image-1', fileExtension: '.png'), + SimpleImageData(id: 'lineup-image-2', fileExtension: '.png'), + ], + ), + throwsA(isA()), + ); + + expect(store.values.keys, ['account-a|existing-image']); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .map((job) => job.assetPublicId), + ['existing-image'], + ); + }); + + test('restart promotes a staged lineup batch without opening its strategy', + () async { + const lineupOp = LineupAddOp( + opId: 'lineup-op', + lineupPublicId: 'lineup-a', + pagePublicId: 'page-a', + payload: { + 'images': [ + {'id': 'lineup-image-1'}, + {'id': 'lineup-image-2'}, + ], + }, + sortIndex: 0, + ); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.putAll([ + for (final assetId in ['lineup-image-1', 'lineup-image-2']) + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ]); + final strategyStore = MemoryDurableStrategyOutboxStore(); + await strategyStore.put(_durableRecord(lineupOp)); + final container = _container( + mediaStore, + strategyStore: strategyStore, + strategyOpen: false, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(container.read(strategyProvider).isOpen, isFalse); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .map((job) => job.referenceDurable), + everyElement(isTrue), + ); + }); + + test('restart recovers when the strategy op was acked before promotion', + () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: 'acked-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'acked-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + var snapshotReads = 0; + final snapshot = {'acked-image'}; + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + strategyOpen: false, + cloudReady: true, + referenceLoader: (strategyId, _) async { + snapshotReads += 1; + expect(strategyId, 'strategy-a'); + return snapshot; + }, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(snapshotReads, greaterThanOrEqualTo(1)); + expect(container.read(strategyProvider).isOpen, isFalse); + expect( + container + .read(cloudMediaUploadQueueProvider) + .jobs + .single + .referenceDurable, + isTrue, + ); + }); + + test('restart removes only an unreferenced staged job, not its source', + () async { + const assetId = 'orphan-image'; + final source = await PlacedImageProvider.getImageFile( + strategyID: 'strategy-a', + imageID: assetId, + fileExtension: '.png', + ); + await source.writeAsBytes([1, 2, 3]); + addTearDown(() async { + if (await source.exists()) await source.delete(); + }); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + strategyOpen: false, + cloudReady: true, + referenceLoader: (_, __) async => {}, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(mediaStore.values, isEmpty); + expect(await source.exists(), isTrue); + }); + + test('missing source job is removed after its reference is deleted', + () async { + final assetId = 'deleted-image-${DateTime.now().microsecondsSinceEpoch}'; + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 3, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + cloudEnabled: true, + referenceLoader: (_, __) async => {}, + ); + addTearDown(container.dispose); + + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 30)); + + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(mediaStore.values, isEmpty); + }); + + test('a newly staged job is not pruned before its mutation is admitted', + () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final container = _container( + mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + cloudReady: true, + referenceLoader: (_, __) async => {}, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'new-image', + fileExtension: '.png', + ); + await queue.retryNow(ignoreBackoff: true); + + final job = container.read(cloudMediaUploadQueueProvider).jobs.single; + expect(job.assetPublicId, 'new-image'); + expect(job.referenceDurable, isFalse); + expect(mediaStore.values, contains('account-a|new-image')); + }); + + test('restart preserves an uploaded object that still needs attachment', + () async { + final store = MemoryDurableCloudMediaOutboxStore(); + await store.put( + CloudMediaUploadJob( + jobId: 'pending-attach-image', + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: 'pending-attach-image', + fileExtension: '.png', + mimeType: 'image/png', + provider: 'r2', + uploadId: 'upload-a', + objectKey: 'strategies/strategy-a/images/pending-attach-image.png', + etag: 'etag-a', + byteSize: 1024, + state: CloudMediaJobState.pendingAttach, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ); + + final restarted = _container(store); + addTearDown(restarted.dispose); + final job = restarted.read(cloudMediaUploadQueueProvider).jobs.single; + await Future.delayed(const Duration(milliseconds: 20)); + + expect(job.state, CloudMediaJobState.pendingAttach); + expect(job.uploadId, 'upload-a'); + expect(job.objectKey, contains('pending-attach-image.png')); + expect(job.etag, 'etag-a'); + }); + + test('media from another strategy does not affect the active save state', () { + final mediaQueue = _MutableMediaQueue(); + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategyOpQueueProvider.overrideWith(_SettledOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + ], + ); + addTearDown(container.dispose); + container.read(strategySaveStateProvider); + + mediaQueue.replaceJobs([ + CloudMediaUploadJob( + jobId: 'other-image', + accountId: 'account-a', + strategyPublicId: 'strategy-b', + assetPublicId: 'other-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.failed, + attempts: 1, + lastError: 'Local media file is missing.', + updatedAt: DateTime.utc(2026, 9, 3), + ), + ]); + + final saveState = container.read(strategySaveStateProvider); + expect(saveState.hasPendingMediaSync, isFalse); + expect(saveState.mediaSyncErrorCount, 0); + expect(saveState.isDirty, isFalse); + }); + + group('work discarded with a page the server deleted', () { + PendingMediaKey key(String assetId) => ( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + ); + + CloudMediaUploadJob job(String assetId, {bool referenceDurable = true}) => + CloudMediaUploadJob( + jobId: assetId, + accountId: 'account-a', + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: referenceDurable, + attempts: 3, + updatedAt: DateTime.utc(2026, 9, 3), + ); + + /// Offline until [goOnline]; the server has 'server-image' on a page. + ({ + ProviderContainer container, + _UploadRecordingRepository repository, + void Function() goOnline, + }) setUp({ + required MemoryDurableCloudMediaOutboxStore mediaStore, + required MemoryDurableStrategyOutboxStore strategyStore, + PendingMediaBytesStore? bytesStore, + CloudMediaReferenceLoader? referenceLoader, + _UploadRecordingRepository? serverRepository, + }) { + var online = false; + final repository = serverRepository ?? _UploadRecordingRepository(); + final container = ProviderContainer(overrides: [ + durableCloudMediaOutboxStoreProvider.overrideWithValue(mediaStore), + convexStrategyRepositoryProvider.overrideWithValue(repository), + durableStrategyOutboxStoreProvider.overrideWithValue(strategyStore), + authProvider.overrideWith(_CloudReadyAuthProvider.new), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudCollabModeProvider.overrideWith(_EnabledCloudCollabMode.new), + convexConnectionSnapshotProvider.overrideWith((ref) => online), + convexConnectionProvider.overrideWith((ref) => Stream.value(false)), + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategyOpQueueProvider.overrideWith(_SettledOpQueue.new), + imageFilesOnDeviceProvider.overrideWithValue(false), + pendingMediaBytesStoreProvider + .overrideWithValue(bytesStore ?? MemoryPendingMediaBytesStore()), + // With [serverRepository], its answers are the server's. + if (serverRepository == null) + cloudMediaReferenceLoaderProvider.overrideWithValue( + referenceLoader ?? (_, __) async => {'server-image'}, + ), + ]); + return ( + container: container, + repository: repository, + goOnline: () { + online = true; + container.invalidate(convexConnectionSnapshotProvider); + }, + ); + } + + test('drops an upload only that work referenced, and its bytes', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + for (final assetId in ['page-image', 'queued-image', 'server-image']) { + await mediaStore.put(job(assetId)); + } + // Another page's change, still queued, places 'queued-image'. + final strategyStore = MemoryDurableStrategyOutboxStore(); + await strategyStore.put(_durableRecord(const ElementAddOp( + opId: 'other-page', + elementPublicId: 'queued-image', + pagePublicId: 'page-b', + sortIndex: 0, + payload: {'id': 'queued-image'}, + ))); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: strategyStore, + ); + addTearDown(container.dispose); + // The other two have no bytes here: an upload missing its bytes goes + // unless something references its image, so they stay only if the + // references hold them. + final bytes = container.read(pendingMediaBytesProvider.notifier); + await bytes.put(key('page-image'), Uint8List.fromList([1, 2, 3])); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + + // Offline when the page's work goes; checked once the server answers. + await queue.recheckAfterDiscardedWork('strategy-a'); + goOnline(); + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + mediaStore.load().jobs.map((job) => job.assetPublicId).toSet(), + {'queued-image', 'server-image'}, + ); + expect(bytes.bytesFor(key('page-image')), isNull); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test( + 'keeps an upload whose image the server still names, whatever the ' + 'full snapshot shows', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put(job('page-image')); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + // The full snapshot shows only the pages on screen; the reference + // list counts every image the server's content still shows. + serverRepository: _ReferencingRepository({'page-image'}), + ); + addTearDown(container.dispose); + final bytes = container.read(pendingMediaBytesProvider.notifier); + await bytes.put(key('page-image'), Uint8List.fromList([1, 2, 3])); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + + await queue.recheckAfterDiscardedWork('strategy-a'); + goOnline(); + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + mediaStore.load().jobs.map((job) => job.assetPublicId), + ['page-image'], + ); + expect(bytes.bytesFor(key('page-image')), [1, 2, 3]); + }); + + test( + 'a staged upload keeps its check, and its bytes, while the server ' + 'cannot tell', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + // Staged: the change placing its image has not landed, so only the + // server can say whether anything still shows it. + await mediaStore.put(job('page-image', referenceDurable: false)); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + serverRepository: _ReferencingRepository(null), + ); + addTearDown(container.dispose); + final bytes = container.read(pendingMediaBytesProvider.notifier); + await bytes.put(key('page-image'), Uint8List.fromList([1, 2, 3])); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + + await queue.recheckAfterDiscardedWork('strategy-a'); + goOnline(); + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + mediaStore.load().jobs.map((job) => job.assetPublicId), + ['page-image'], + ); + expect(bytes.bytesFor(key('page-image')), [1, 2, 3]); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test('a restart before the server answers still checks', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put(job('page-image')); + final bytesStore = MemoryPendingMediaBytesStore(); + final first = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + bytesStore: bytesStore, + ); + await first.container + .read(pendingMediaBytesProvider.notifier) + .put(key('page-image'), Uint8List.fromList([1, 2, 3])); + await first.container + .read(cloudMediaUploadQueueProvider.notifier) + .recheckAfterDiscardedWork('strategy-a'); + first.container.dispose(); + + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + bytesStore: bytesStore, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + goOnline(); + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(mediaStore.load().jobs, isEmpty); + expect(bytesStore.load(), isEmpty); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test('a failed removal is tried again, never uploaded, and waits alone', + () async { + final mediaStore = _FailingBatchStore()..failRemove = true; + await mediaStore.put(job('page-image')); + await mediaStore.put(job('queued-image')); + final strategyStore = MemoryDurableStrategyOutboxStore(); + await strategyStore.put(_durableRecord(const ElementAddOp( + opId: 'other-page', + elementPublicId: 'queued-image', + pagePublicId: 'page-b', + sortIndex: 0, + payload: {'id': 'queued-image'}, + ))); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: strategyStore, + ); + addTearDown(container.dispose); + final bytes = container.read(pendingMediaBytesProvider.notifier); + await bytes.put(key('page-image'), Uint8List.fromList([1, 2, 3])); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + goOnline(); + + // Restored at launch, so already marked for the check. + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + final jobs = { + for (final job in mediaStore.load().jobs) job.assetPublicId: job, + }; + expect(jobs.keys, containsAll(['page-image', 'queued-image'])); + expect(jobs['page-image']!.attempts, 3); + // The other upload was tried meanwhile (its bytes are gone here). + expect(jobs['queued-image']!.attempts, 4); + + mediaStore.failRemove = false; + await queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 50)); + + expect( + mediaStore.load().jobs.map((job) => job.assetPublicId), + ['queued-image'], + ); + expect(bytes.bytesFor(key('page-image')), isNull); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test('a staged upload whose removal fails keeps retrying', () async { + final mediaStore = _FailingBatchStore()..failRemove = true; + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'late-image', + fileExtension: '.png', + ); + goOnline(); + + await queue.recheckAfterDiscardedWork('strategy-a'); + expect(mediaStore.load().jobs, hasLength(1)); + + mediaStore.failRemove = false; + await queue.retryNow(ignoreBackoff: true); + expect(mediaStore.load().jobs, isEmpty); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test('a staged upload keeps its check through a failed server read', + () async { + var reads = 0; + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + referenceLoader: (_, __) async { + reads += 1; + if (reads == 1) throw StateError('read failed'); + return {}; + }, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'late-image', + fileExtension: '.png', + ); + goOnline(); + + await queue.recheckAfterDiscardedWork('strategy-a'); + expect(mediaStore.load().jobs, hasLength(1)); + + await queue.retryNow(ignoreBackoff: true); + expect(mediaStore.load().jobs, isEmpty); + }); + + test('a retry during a check waits for it, uploading nothing', () async { + final snapshot = Completer?>(); + var reads = 0; + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put(job('page-image')); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + referenceLoader: (_, __) { + reads += 1; + // A second, overlapping check could not tell and let it upload. + if (reads > 1) throw StateError('read failed'); + return snapshot.future; + }, + ); + addTearDown(container.dispose); + await container + .read(pendingMediaBytesProvider.notifier) + .put(key('page-image'), Uint8List.fromList([1, 2, 3])); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + goOnline(); + + final first = queue.retryNow(ignoreBackoff: true); + await Future.delayed(Duration.zero); + final second = queue.retryNow(ignoreBackoff: true); + await Future.delayed(const Duration(milliseconds: 20)); + expect(repository.uploadedAssetIds, isEmpty); + + snapshot.complete({}); + await Future.wait([first, second]); + await Future.delayed(const Duration(milliseconds: 20)); + + expect(reads, 1); + expect(mediaStore.load().jobs, isEmpty); + expect(repository.uploadedAssetIds, isEmpty); + }); + + test('drops an image placed after the delete that never got a change', + () async { + // Nothing can send a change for a page the server no longer has, so + // this upload's reference is never admitted. + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final (:container, :repository, :goOnline) = setUp( + mediaStore: mediaStore, + strategyStore: MemoryDurableStrategyOutboxStore(), + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + await queue.enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: 'late-image', + fileExtension: '.png', + ); + + await queue.recheckAfterDiscardedWork('strategy-a'); + goOnline(); + await queue.retryNow(ignoreBackoff: true); + + expect(mediaStore.load().jobs, isEmpty); + expect(repository.uploadedAssetIds, isEmpty); + }); + }); +} + +/// Records each upload the queue starts and refuses it, as if the network +/// dropped, so the job stays. +class _UploadRecordingRepository implements ConvexStrategyRepository { + final List uploadedAssetIds = []; + + @override + Future generateImageUploadUrl({ + required String strategyPublicId, + required String assetPublicId, + required String mimeType, + required String fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + uploadedAssetIds.add(assetPublicId); + throw StateError('network dropped'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// A server whose content shows [referenced] images (null: it cannot tell +/// yet) and whose full snapshot shows none. +class _ReferencingRepository extends _UploadRecordingRepository { + _ReferencingRepository(this.referenced); + + final Set? referenced; + + @override + Future?> fetchReferencedAssetIds( + String strategyPublicId, + Iterable assetPublicIds, + ) async => + referenced?.intersection(assetPublicIds.toSet()); + + @override + Future fetchFullSnapshot( + String strategyPublicId, { + String? shareToken, + }) async { + final now = DateTime.utc(2026, 9, 3); + return RemoteFullStrategySnapshot( + header: RemoteStrategyHeader( + publicId: strategyPublicId, + name: 'Strategy A', + mapData: 'ascent', + revision: 1, + createdAt: now, + updatedAt: now, + ), + pages: const [], + elementsByPage: const {}, + lineupsByPage: const {}, + assetsById: const {}, + ); + } +} diff --git a/test/cloud_payload_upgrade_test.dart b/test/cloud_payload_upgrade_test.dart new file mode 100644 index 00000000..5ec670b1 --- /dev/null +++ b/test/cloud_payload_upgrade_test.dart @@ -0,0 +1,179 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/cloud_payload_upgrade.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/migrations/paranoia_range_migration.dart'; + +const _map = MapValue.haven; + +final _omen = AgentData.agents[AgentType.omen]!; + +PlacedAbility _placed(String id, int index) => PlacedAbility( + id: id, + data: _omen.abilities[index], + position: const Offset(610, 340), + rotation: 0.7, + isAlly: false, + ); + +/// A payload as a 4.6.3-era client wrote it: version 1, old Paranoia size. +CloudPayload _v1(String kind, Map data) { + final payload = cloudElementPayload(kind: kind, data: data); + return {...payload, 'payloadVersion': 1}; +} + +RemotePageSnapshot _page( + List elements, List lineups) { + final now = DateTime.utc(2026, 10, 1); + return RemotePageSnapshot( + page: RemotePage( + publicId: 'page', + strategyPublicId: 'strategy', + name: 'Garage retake', + sortIndex: 0, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ), + content: RemotePageContent( + settings: const {}, + revision: 1, + createdAt: now, + updatedAt: now, + ), + elements: [ + for (final (i, payload) in elements.indexed) + RemoteElement( + publicId: 'element-$i', + strategyPublicId: 'strategy', + pagePublicId: 'page', + elementType: payload['kind'] as String, + payload: payload, + sortIndex: i, + revision: 1, + deleted: false, + ), + ], + lineups: [ + for (final (i, payload) in lineups.indexed) + RemoteLineup( + publicId: 'lineup-$i', + strategyPublicId: 'strategy', + pagePublicId: 'page', + payload: payload, + sortIndex: i, + revision: 1, + deleted: false, + ), + ], + assetsById: const {}, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('a version 1 Paranoia moves exactly as the local migration moves it', + () { + final paranoia = _placed('paranoia', 1); + final upgraded = upgradeCloudPayload( + _v1('ability', paranoia.toJson()), + _map, + ); + + expect(upgraded['payloadVersion'], paranoiaCloudPayloadVersion); + final expected = ParanoiaRangeMigration.migrateAbility(paranoia, _map); + expect(PlacedAbility.fromJson(cloudPayloadData(upgraded)).position, + expected.position); + expect(expected.position, isNot(paranoia.position)); + }); + + test('a lineup carrying a Paranoia is never moved', () { + // Every lineup row was written at the in-game size: correcting one would + // move its Paranoia twice. + final lineup = cloudLineupRows(LineUpGraph( + origins: [ + LineUpOrigin( + id: 'origin', + agent: PlacedAgent( + id: 'omen', + type: AgentType.omen, + position: const Offset(340, 210), + lineUpID: 'origin', + ), + ), + ], + landings: [LineUpLanding(id: 'landing', ability: _placed('l', 1))], + links: [ + LineUpLink(id: 'lineup', originId: 'origin', landingId: 'landing'), + ], + )).rows.single.payload; + + expect(lineup['payloadVersion'], currentCloudPayloadVersion); + expect(identical(upgradeCloudPayload(lineup, _map), lineup), isTrue); + final page = upgradeRemotePageSnapshot( + _page([_v1('ability', _placed('paranoia', 1).toJson())], [lineup]), + Maps.mapNames[_map]!, + ); + expect(identical(page.lineups.single.payload, lineup), isTrue); + }); + + test('rows without a Paranoia are left exactly as the server holds them', () { + final darkCover = _v1('ability', _placed('dark-cover', 2).toJson()); + final agent = cloudElementPayload( + kind: 'agent', + data: PlacedAgent( + id: 'omen', + type: AgentType.omen, + position: const Offset(340, 210), + ).toJson(), + ); + + expect(identical(upgradeCloudPayload(darkCover, _map), darkCover), isTrue); + expect(identical(upgradeCloudPayload(agent, _map), agent), isTrue); + expect(agent['payloadVersion'], currentCloudPayloadVersion); + }); + + test( + 'what this build writes reads back unchanged, so it is never moved twice', + () { + final written = cloudElementPayload( + kind: 'ability', + data: _placed('paranoia', 1).toJson(), + ); + + expect(written['payloadVersion'], paranoiaCloudPayloadVersion); + expect(identical(upgradeCloudPayload(written, _map), written), isTrue); + }); + + test('a page snapshot is upgraded row by row', () { + final paranoia = _v1('ability', _placed('paranoia', 1).toJson()); + final darkCover = _v1('ability', _placed('dark-cover', 2).toJson()); + final page = upgradeRemotePageSnapshot( + _page([paranoia, darkCover], const []), + Maps.mapNames[_map]!, + ); + + expect( + page.elements[0].payload['payloadVersion'], + paranoiaCloudPayloadVersion, + ); + expect(identical(page.elements[1].payload, darkCover), isTrue); + }); + + test('a map this build does not know leaves the page as it is', () { + final source = _page( + [_v1('ability', _placed('paranoia', 1).toJson())], + const [], + ); + expect( + identical(upgradeRemotePageSnapshot(source, 'not-a-map-yet'), source), + isTrue, + ); + }); +} diff --git a/test/cloud_sign_out_coordinator_test.dart b/test/cloud_sign_out_coordinator_test.dart new file mode 100644 index 00000000..601ad3e9 --- /dev/null +++ b/test/cloud_sign_out_coordinator_test.dart @@ -0,0 +1,431 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/services/cloud_sign_out_coordinator.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('cancel keeps durable inactive strategy and media work signed in', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final container = _container( + opQueue: _pendingQueue(), + mediaQueue: _pendingMedia(), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Cloud work is still waiting'), findsOneWidget); + expect( + find.textContaining('3 saved changes across 3 strategies'), + findsOneWidget, + ); + expect(find.textContaining('remains on this device'), findsOneWidget); + expect(find.textContaining('same account signs in again'), findsOneWidget); + expect(find.textContaining('Bind retake'), findsOneWidget); + expect(find.textContaining('Ascent execute'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 0); + expect(editorCloses, 0); + }); + + testWidgets( + 'confirmed sign out closes the cloud editor without clearing work', + (tester) async { + var rawSignOuts = 0; + var editorCloses = 0; + final opQueue = _pendingQueue(); + final mediaQueue = _pendingMedia(); + final container = _container( + opQueue: opQueue, + mediaQueue: mediaQueue, + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out Anyway')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(rawSignOuts, 1); + expect(opQueue.accountOutbox.workCount, 2); + expect(mediaQueue.jobs, hasLength(1)); + }); + + testWidgets('failed local persistence blocks sign out on screen', + (tester) async { + var rawSignOuts = 0; + final container = _container( + preparation: () async => throw StateError('disk full'), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect( + find.textContaining('could not confirm that all pending cloud work'), + findsOneWidget, + ); + expect(find.text('Sign Out Anyway'), findsNothing); + expect(rawSignOuts, 0); + }); + + testWidgets('preparation commits drafts and asks both outboxes to persist', + (tester) async { + final strategy = _PreparingCloudStrategy(); + final media = _PreparingMediaQueue(); + final container = ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(() => strategy), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith( + () => _FixedOpQueue(const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + durableLoaded: true, + )), + ), + cloudMediaUploadQueueProvider.overrideWith(() => media), + cloudEditorCloseProvider.overrideWithValue(() async {}), + rawSignOutProvider.overrideWithValue(() async => true), + cloudStrategyNamesProvider.overrideWithValue(const {}), + ]); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('draft-no-longer-mounted', 'last thought'); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + + expect(strategy.forceSaveCount, 1); + expect(media.retryCount, 1); + expect(container.read(textDraftProvider), isEmpty); + expect(find.text('Sign out?'), findsOneWidget); + }); + + testWidgets('an unreliable durable outbox blocks sign out', (tester) async { + var rawSignOuts = 0; + final container = _container( + opQueue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + hasDurabilityFailure: true, + ), + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text("Can't sign out yet"), findsOneWidget); + expect(rawSignOuts, 0); + }); + + testWidgets('normal sign out still asks for explicit confirmation', + (tester) async { + var rawSignOuts = 0; + final container = _container( + rawSignOut: () async { + rawSignOuts += 1; + return true; + }, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + expect(rawSignOuts, 1); + }); + + testWidgets('another account media is not shown or relabeled', + (tester) async { + final otherAccountMedia = CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'account-b-media', + accountId: 'account-b', + strategyPublicId: 'account-b-strategy', + assetPublicId: 'account-b-media', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); + final container = _container(mediaQueue: otherAccountMedia); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + expect(find.text('Sign out?'), findsOneWidget); + expect(find.text('Cloud work is still waiting'), findsNothing); + expect(find.textContaining('account-b'), findsNothing); + }); + + testWidgets('raw sign-out failure does not report success or navigate', + (tester) async { + var editorCloses = 0; + final container = _container( + rawSignOut: () async => false, + closeEditor: () async => editorCloses += 1, + ); + addTearDown(container.dispose); + await _pumpHarness(tester, container); + + await tester.tap(find.text('Request sign out')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + + expect(editorCloses, 1); + expect(find.text('Sign out failed'), findsOneWidget); + expect(find.text('Request sign out'), findsOneWidget); + }); +} + +StrategyOpQueueState _pendingQueue() { + return const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'active-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'active-strategy': StrategyOutboxSummary( + strategyPublicId: 'active-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ); +} + +CloudMediaUploadQueueState _pendingMedia() { + return CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'media-one', + accountId: 'account-a', + strategyPublicId: 'media-strategy', + assetPublicId: 'media-one', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: true, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ); +} + +ProviderContainer _container({ + StrategyOpQueueState opQueue = const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + ), + CloudMediaUploadQueueState mediaQueue = const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ), + CloudSignOutPreparation? preparation, + RawSignOut? rawSignOut, + CloudEditorClose? closeEditor, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + strategyProvider.overrideWith(_ActiveCloudStrategy.new), + strategySaveStateProvider.overrideWith(_CleanSaveState.new), + strategyOpQueueProvider.overrideWith(() => _FixedOpQueue(opQueue)), + cloudMediaUploadQueueProvider.overrideWith( + () => _FixedMediaQueue(mediaQueue), + ), + cloudSignOutPreparationProvider.overrideWithValue( + preparation ?? () async {}, + ), + rawSignOutProvider.overrideWithValue(rawSignOut ?? () async => true), + cloudEditorCloseProvider.overrideWithValue(closeEditor ?? () async {}), + cloudStrategyNamesProvider.overrideWithValue(const { + 'active-strategy': 'Active strategy', + 'closed-strategy': 'Bind retake', + 'media-strategy': 'Ascent execute', + }), + ]); +} + +Future _pumpHarness( + WidgetTester tester, + ProviderContainer container, +) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp(home: _SignOutHarness()), + ), + ); +} + +class _SignOutHarness extends ConsumerWidget { + const _SignOutHarness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: ShadButton( + onPressed: () => unawaited( + ref.read(cloudSignOutRequestProvider)(context), + ), + child: const Text('Request sign out'), + ), + ), + ); + } +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _ActiveCloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'active-strategy', + strategyName: 'Active strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); +} + +class _PreparingCloudStrategy extends _ActiveCloudStrategy { + int forceSaveCount = 0; + + @override + Future forceSaveNow(String id) async { + forceSaveCount += 1; + } +} + +class _CleanSaveState extends StrategySaveStateNotifier { + @override + StrategySaveState build() => const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ); +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FixedMediaQueue extends CloudMediaUploadQueueNotifier { + _FixedMediaQueue(this.initialState); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +class _PreparingMediaQueue extends CloudMediaUploadQueueNotifier { + int retryCount = 0; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future retryNow({bool ignoreBackoff = false}) async { + retryCount += 1; + } +} diff --git a/test/cloud_startup_test.dart b/test/cloud_startup_test.dart new file mode 100644 index 00000000..6b388cb0 --- /dev/null +++ b/test/cloud_startup_test.dart @@ -0,0 +1,203 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/config/cloud_startup.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/startup/cloud_bootstrap.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +void main() { + setUpAll(() { + appProviderContainer = ProviderContainer(); + registerIcarusAdapters(Hive); + }); + + tearDownAll(() => appProviderContainer.dispose()); + + group('startCloud', () { + test('an outbox that will not open leaves the cloud off, unstarted', + () async { + var clientsStarted = false; + final startup = await startCloud( + openOutboxes: () async => throw HiveError('Wrong checksum'), + initializeClients: () async => clientsStarted = true, + ); + + expect(startup.isAvailable, isFalse); + expect(startup.unavailableReason, cloudOutboxUnavailableReason); + expect(clientsStarted, isFalse); + }); + + test('clients that fail to start leave the cloud off', () async { + final startup = await startCloud( + openOutboxes: () async {}, + initializeClients: () async => + throw StateError('Failed to load the Convex library'), + ); + + expect(startup.isAvailable, isFalse); + expect(startup.unavailableReason, cloudClientsUnavailableReason); + }); + + test('the cloud is up when every step succeeds', () async { + final startup = await startCloud( + openOutboxes: () async {}, + initializeClients: () async {}, + ); + + expect(startup.isAvailable, isTrue); + }); + }); + + group('openCloudOutboxes', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-outbox-open-'); + Hive.init(tempDir.path); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + test('a write torn by a crash is trimmed and earlier ops survive', + () async { + // Two saved ops, then a crash partway through writing a third. + final box = await Hive.openBox(HiveBoxNames.strategyOutboxBox); + await box.put('first-op', 'saved'); + await box.put('second-op', 'also saved'); + await box.close(); + final file = + File('${tempDir.path}/${HiveBoxNames.strategyOutboxBox}.hive'); + final saved = file.readAsBytesSync(); + + final scratch = await Hive.openBox('scratch'); + await scratch.put('torn-op', 'never finished writing'); + await scratch.close(); + final tornFrame = File('${tempDir.path}/scratch.hive').readAsBytesSync(); + file.writeAsBytesSync([ + ...saved, + ...tornFrame.sublist(0, tornFrame.length ~/ 2), + ]); + + await openCloudOutboxes(); + + final outbox = Hive.box(HiveBoxNames.strategyOutboxBox); + expect(outbox.get('first-op'), 'saved'); + expect(outbox.get('second-op'), 'also saved'); + expect(outbox.containsKey('torn-op'), isFalse); + }); + }); + + group('with the cloud off', () { + late Directory tempDir; + + setUp(() async { + AuthProvider.resetTestOverrides(); + tempDir = await Directory.systemTemp.createTemp('icarus-cloud-off-'); + Hive.init(tempDir.path); + // The local library opened; the cloud outboxes did not. + final strategies = + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await MapThemeProfilesProvider.bootstrap(); + final at = DateTime(2026, 9, 1); + await strategies.put( + 'local-plan', + StrategyData( + id: 'local-plan', + name: 'Local Plan', + mapData: MapValue.ascent, + versionNumber: 1, + folderID: null, + pages: const [], + createdAt: at, + lastEdited: at, + ), + ); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + testWidgets( + 'the local library opens and sign-in says why it is unavailable', + (tester) async { + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // Real auth and queues: none of them may reach Supabase, Convex, or an + // outbox box, since none of those exist in this run. + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStartupProvider.overrideWith( + (ref) => const CloudStartup.unavailable( + cloudOutboxUnavailableReason, + ), + ), + platformPolicyProvider.overrideWithValue(PlatformPolicy.desktop), + appUpdateStatusProvider.overrideWith( + (ref) async => const UpdateCheckResult( + isSupported: false, + isUpdateAvailable: false, + source: 'test', + ), + ), + desktopUpdateControllerProvider.overrideWithValue(null), + ], + child: const ToastificationWrapper( + child: ShadApp(home: FolderNavigator()), + ), + ), + ); + await tester.pumpAndSettle(); + + // What MyApp reads at startup comes up without throwing. + final container = ProviderScope.containerOf( + tester.element(find.byType(FolderNavigator)), + ); + expect(container.read(strategyOpQueueProvider).durableLoaded, isTrue); + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + expect(container.read(authProvider).isAuthenticated, isFalse); + + expect(find.text('Local Plan'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('library-account-action'))); + await tester.pumpAndSettle(); + expect(find.byType(AuthDialog), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('auth-discord-button'))); + await tester.pumpAndSettle(); + expect(find.text(cloudOutboxUnavailableReason), findsOneWidget); + }); + }); +} diff --git a/test/cloud_ui_parity_helpers_test.dart b/test/cloud_ui_parity_helpers_test.dart new file mode 100644 index 00000000..d11e65b5 --- /dev/null +++ b/test/cloud_ui_parity_helpers_test.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; + +void main() { + test('cloud folder values adapt to local folder model defaults', () { + final folder = Folder( + id: 'folder-1', + name: 'Set Plays', + dateCreated: DateTime(2026, 1, 1), + parentID: 'parent-1', + iconId: folderIconIdFromCloud( + iconId: null, + codePoint: null, + fontFamily: null, + fontPackage: null, + ), + color: folderColorFromWireName(null), + ); + + expect(folder.id, 'folder-1'); + expect(folder.name, 'Set Plays'); + expect(folder.parentID, 'parent-1'); + expect(folder.color, FolderColor.generic); + expect(folder.icon.codePoint, Icons.drive_folder_upload.codePoint); + expect(folder.customColor, isNull); + }); + + test('cloud folder values preserve icon and color metadata', () { + final folder = Folder( + id: 'folder-2', + name: 'Execs', + dateCreated: DateTime(2026, 1, 1), + iconId: folderIconIdFromCloud( + iconId: FolderIconRegistry.duelistRoleId, + codePoint: null, + fontFamily: null, + fontPackage: null, + ), + color: folderColorFromWireName('red'), + customColor: folderCustomColorFromCloud(0xFF123456), + ); + + expect(folder.iconId, FolderIconRegistry.duelistRoleId); + expect(folder.color, FolderColor.red); + expect(folder.customColor, const Color(0xFF123456)); + }); + + test('viewer cloud capabilities disable mutations', () { + final caps = StrategyCapabilities.fromCloudRole('viewer'); + + expect(caps.canRenameStrategy, isFalse); + expect(caps.canDeleteStrategy, isFalse); + expect(caps.canMoveStrategy, isFalse); + expect(caps.canEditPages, isFalse); + expect(caps.canAddPage, isFalse); + expect(caps.canReorderPages, isFalse); + }); + + test('owner cloud capabilities allow destructive actions', () { + final caps = StrategyCapabilities.fromCloudRole('owner'); + + expect(caps.canRenameStrategy, isTrue); + expect(caps.canDeleteStrategy, isTrue); + expect(caps.canMoveStrategy, isTrue); + expect(caps.canEditPages, isTrue); + expect(caps.canAddPage, isTrue); + expect(caps.canReorderPages, isTrue); + }); + + test('editor cloud capabilities allow page edits but not moves', () { + final caps = StrategyCapabilities.fromCloudRole('editor'); + + expect(caps.canRenameStrategy, isTrue); + expect(caps.canMoveStrategy, isFalse); + expect(caps.canEditPages, isTrue); + }); +} diff --git a/test/collab/cloud_sync_error_message_test.dart b/test/collab/cloud_sync_error_message_test.dart new file mode 100644 index 00000000..bf96130b --- /dev/null +++ b/test/collab/cloud_sync_error_message_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/src/convex_client_types.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +void main() { + test('turns a forbidden Convex failure into a permission explanation', () { + final message = friendlyCloudSyncError( + 'ConvexFunctionException(FORBIDDEN, Forbidden) ' + '(retry paused after 8 attempts)', + ); + + expect(message, contains('does not have permission')); + expect(message, contains('remain on this device')); + expect(message, isNot(contains('ConvexFunctionException'))); + }); + + test('explains a lineup that clashes with one on another page', () { + final message = friendlyCloudSyncError( + 'ConvexFunctionException(LINEUP_PAGE_MISMATCH, This lineup belongs to ' + 'another page and cannot be moved)', + ); + + expect(message, contains('another page')); + expect(message, contains('remains on this device')); + expect(message, contains('Use cloud removes it here')); + expect(message, isNot(contains('Keep mine'))); + expect(message, isNot(contains('LINEUP_PAGE_MISMATCH'))); + }); + + test('explains a change to something a teammate deleted', () { + final message = friendlyCloudSyncError(teammateDeletedMessage); + + expect(message, contains('A teammate deleted what you changed')); + expect(message, contains('Keep mine brings it back')); + expect(message, contains('Use cloud lets it go')); + }); + + test('says when Keep mine cannot bring a deleted item back', () { + final message = friendlyCloudSyncError(teammateDeletedCannotRestoreMessage); + + expect(message, contains('cannot bring it back')); + expect(message, contains('Use cloud')); + expect(message, isNot(contains('Keep mine'))); + expect(message, isNot(contains('matching revision'))); + }); + + test('explains a lineup change saved in the old cloud format', () { + final message = friendlyCloudSyncError(retiredLineupOpMessage); + + expect(message, contains('older version of Icarus')); + expect(message, contains('cannot be sent')); + expect(message, contains('Use cloud')); + expect(message, isNot(contains('Keep mine'))); + }); + + test('explains a lineup group refused for overlapping another', () { + final message = friendlyCloudSyncError(lineupOverlapMessage); + + expect(message, contains('shares a spot')); + expect(message, contains('not saved to the cloud')); + expect(message, contains('Use cloud')); + expect(message, isNot(contains(lineupOverlapMessage))); + expect(isSpecificAttentionReason(lineupOverlapMessage), isTrue); + // As the queue reports it beside other refused work. + expect( + isSpecificAttentionReason( + '$lineupOverlapMessage. $otherWorkNeedsAttentionNote'), + isTrue, + ); + }); + + test('lineup refusals and oversized work are specific attention reasons', () { + expect(isSpecificAttentionReason(lineupPageMismatchMessage), isTrue); + expect(isSpecificAttentionReason(retiredLineupOpMessage), isTrue); + expect(isSpecificAttentionReason(teammateDeletedMessage), isTrue); + expect( + isSpecificAttentionReason(teammateDeletedCannotRestoreMessage), + isTrue, + ); + expect(isSpecificAttentionReason(pageDeletedMessage), isTrue); + expect(isSpecificAttentionReason(cloudOperationTooLargeMessage), isTrue); + for (final reason in [ + 'Some saved work needs attention.', + 'revision_mismatch', + 'The server rejected this change.', + ]) { + expect(isSpecificAttentionReason(reason), isFalse, reason: reason); + } + }); + + test('does not expose unknown transport details', () { + final message = friendlyCloudSyncError('socket exploded at 10.0.0.4'); + + expect(message, "Some changes haven't reached the cloud yet."); + }); + + test('does not promise local durability when the outbox is uncertain', () { + final message = friendlyCloudSyncError( + 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.', + ); + + expect(message, contains('could not verify')); + expect(message, contains('Nothing was sent')); + expect(message, isNot(contains('remains saved'))); + }); + + test('a failed outbox write is not called merely unsent', () { + final message = friendlyCloudSyncError( + 'Cloud work could not be saved to the durable outbox: ' + 'Bad state: disk write failed', + ); + + expect(message, unverifiedCloudWorkMessage); + }); + + group('a server that needs a newer Icarus', () { + test('is told on the web to reload', () { + expect( + clientUpgradeRequiredMessage(isWeb: true), + 'Icarus was updated. Reload to keep syncing.', + ); + }); + + test('is told on desktop to install the update, once there is one', () { + expect( + clientUpgradeRequiredMessage(isWeb: false), + 'Icarus was updated. Install the update to keep syncing.', + ); + expect( + clientUpgradeRequiredMessage( + isWeb: false, + update: ClientUpdateAvailability.unavailable, + ), + "Icarus was updated, but the update isn't available for this app " + 'yet. Your saved work stays on this device and syncs once the update is ' + 'installed.', + ); + expect( + clientUpgradeRequiredMessage( + isWeb: false, + update: ClientUpdateAvailability.checking, + ), + isNot(contains('Install')), + ); + }); + + test('is known from the typed refusal', () { + for (final error in [ + const ConvexFunctionException( + code: ConvexErrorCode.clientUpgradeRequired, + rawCode: 'CLIENT_UPGRADE_REQUIRED', + message: 'Client upgrade required', + ), + const ConvexClientFunctionError( + rawCode: 'CLIENT_UPGRADE_REQUIRED', + message: 'Client upgrade required', + data: null, + ), + const ConvexTransportError( + rawCode: 'CLIENT_UPGRADE_REQUIRED', + message: 'Client upgrade required', + ), + ]) { + expect(isClientUpgradeRequiredError(error), isTrue, reason: '$error'); + } + }); + + test('is known from exactly the text a refusal leaves behind', () { + for (final reason in [ + // What the outbox kept, in this build and the protocol 3 web build. + 'ConvexFunctionException(CLIENT_UPGRADE_REQUIRED, Client upgrade ' + 'required)', + clientUpgradeRequiredQueueError, + ]) { + expect(isClientUpgradeRequiredReason(reason), isTrue, reason: reason); + expect(isClientUpgradeRequiredError(reason), isTrue, reason: reason); + expect( + friendlyCloudSyncError(reason), + clientUpgradeRequiredMessage(), + reason: reason, + ); + } + }); + + test('is not any other error, even one that mentions the code', () { + for (final error in [ + null, + 'ConvexFunctionException(FORBIDDEN, Forbidden)', + 'Cloud connection is offline.', + // How an argument validation failure reads: a client that sends a + // field the server no longer knows, or omits one it requires. + 'ConvexClientFunctionError(CONVEX_ERROR, ArgumentValidationError: ' + 'Object is missing the required field `clientProtocolVersion`.)', + // Text that only mentions the code is no refusal. + 'ConvexFunctionException(INVALID_PAYLOAD, name was ' + 'CLIENT_UPGRADE_REQUIRED)', + 'CLIENT_UPGRADE_REQUIRED', + StateError('CLIENT_UPGRADE_REQUIRED'), + const ConvexFunctionException( + code: ConvexErrorCode.invalidPayload, + rawCode: 'INVALID_PAYLOAD', + message: 'CLIENT_UPGRADE_REQUIRED', + ), + ]) { + expect(isClientUpgradeRequiredError(error), isFalse, reason: '$error'); + } + expect( + friendlyCloudSyncError( + 'ConvexFunctionException(INVALID_PAYLOAD, name was ' + 'CLIENT_UPGRADE_REQUIRED)', + ), + isNot(clientUpgradeRequiredMessage()), + ); + }); + }); +} diff --git a/test/collab/convex_read_protocol_test.dart b/test/collab/convex_read_protocol_test.dart new file mode 100644 index 00000000..c93e5401 --- /dev/null +++ b/test/collab/convex_read_protocol_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +/// The server refuses element and lineup payloads, and the shell, to a +/// client that does not say which protocol it reads, so every such read +/// says it. +void main() { + late _RecordingTransport transport; + late ConvexStrategyRepository repository; + + setUp(() { + transport = _RecordingTransport(); + repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + }); + + void expectProtocolSent(String name) { + final (calledName, args) = transport.calls.single; + expect(calledName, name); + expect( + args.value['clientProtocolVersion']?.toDart(), + currentCloudProtocolVersion, + ); + } + + test('the shell is read with the protocol', () async { + await expectLater(repository.fetchShell('strategy'), throwsStateError); + expectProtocolSent('strategy:getShell'); + }); + + test('the shell is watched with the protocol', () async { + await expectLater( + repository.watchShell('strategy').first, throwsStateError); + expectProtocolSent('strategy:getShell'); + }); + + test('a page snapshot is read with the protocol', () async { + await expectLater( + repository.fetchPageSnapshot( + strategyPublicId: 'strategy', + pagePublicId: 'page', + ), + throwsStateError, + ); + expectProtocolSent('page:getSnapshot'); + }); + + test('a page snapshot is watched with the protocol', () async { + await expectLater( + repository + .watchPageSnapshot(strategyPublicId: 'strategy', pagePublicId: 'page') + .first, + throwsStateError, + ); + expectProtocolSent('page:getSnapshot'); + }); + + test('a full snapshot is read with the protocol', () async { + await expectLater( + repository.fetchFullSnapshot('strategy'), + throwsStateError, + ); + expectProtocolSent('strategy:getFullSnapshot'); + }); +} + +final class _RecordingTransport implements ConvexTransport { + final calls = <(String, ConvexObject)>[]; + + @override + Future query(String name, ConvexObject args) async { + calls.add((name, args)); + throw StateError('not answered in this test'); + } + + @override + Stream subscribe(String name, ConvexObject args) { + calls.add((name, args)); + return Stream.error(StateError('not answered in this test')); + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} diff --git a/test/collab/presence_room_test.dart b/test/collab/presence_room_test.dart new file mode 100644 index 00000000..01d307ed --- /dev/null +++ b/test/collab/presence_room_test.dart @@ -0,0 +1,884 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:clock/clock.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/collab/presence/presence_room.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +void main() { + group('applyPresenceMessage', () { + const empty = PresenceRoomState(connection: PresenceConnection.connecting); + + // Without [editing] this is the peer an old room sends: no editing key. + Map peer( + String sid, + String uid, { + Map? cursor, + Object? editing, + }) => + { + 'sid': sid, + 'uid': uid, + 'name': 'Name $uid', + 'avatar': null, + 'role': 'editor', + 'cursor': cursor, + if (editing != null) 'editing': editing, + }; + + test('welcome replaces the room and marks it live', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'self': 's-me', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', cursor: {'page': 'p1', 'x': 10, 'y': 20}), + {'sid': 'broken'}, + ], + }); + expect(state.connection, PresenceConnection.live); + expect(state.selfUid, 'me'); + expect(state.peers.keys, ['s1']); + expect( + state.peers['s1']!.cursor, + const PresenceCursor(pageId: 'p1', x: 10, y: 20), + ); + }); + + test('joins, cursors, hides, and leaves update one peer', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [], + }); + state = + applyPresenceMessage(state, {'t': 'join', 'peer': peer('s1', 'ana')}); + state = applyPresenceMessage( + state, + {'t': 'cursor', 'sid': 's1', 'page': 'p2', 'x': 1.5, 'y': 2}, + ); + expect(state.cursorsOn('p2').single.sid, 's1'); + expect(state.cursorsOn('p1'), isEmpty); + + // A repeat join (profile refresh) keeps the cursor. + state = + applyPresenceMessage(state, {'t': 'join', 'peer': peer('s1', 'ana')}); + expect(state.cursorsOn('p2'), hasLength(1)); + + state = applyPresenceMessage(state, {'t': 'hide', 'sid': 's1'}); + expect(state.cursorsOn('p2'), isEmpty); + state = applyPresenceMessage(state, {'t': 'leave', 'sid': 's1'}); + expect(state.peers, isEmpty); + }); + + test('the page someone is on outlasts their cursor leaving the map', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', cursor: {'page': 'p1', 'x': 1, 'y': 1}), + peer('s2', 'ana', cursor: {'page': 'p2', 'x': 1, 'y': 1}), + peer('s3', 'ben'), + peer('s4', 'me', cursor: {'page': 'p1', 'x': 1, 'y': 1}), + ], + }); + // Ana is on p1 in one window; Ben's cursor has not shown; you are not + // someone else. + expect(state.peopleOn('p1').map((peer) => peer.uid), ['ana']); + expect(state.peopleOn('p2').map((peer) => peer.uid), ['ana']); + + state = applyPresenceMessage(state, {'t': 'hide', 'sid': 's1'}); + expect(state.cursorsOn('p1'), isEmpty); + expect(state.peopleOn('p1').map((peer) => peer.uid), ['ana']); + // A repeat join (profile refresh) keeps it too. + state = + applyPresenceMessage(state, {'t': 'join', 'peer': peer('s1', 'ana')}); + expect(state.peopleOn('p1').map((peer) => peer.uid), ['ana']); + + state = applyPresenceMessage( + state, + {'t': 'cursor', 'sid': 's1', 'page': 'p3', 'x': 1, 'y': 1}, + ); + state = applyPresenceMessage(state, {'t': 'leave', 'sid': 's2'}); + expect(state.peopleOn('p1'), isEmpty); + expect(state.peopleOn('p3').map((peer) => peer.uid), ['ana']); + }); + + test('people are one per person and never yourself', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana'), + peer('s2', 'ana'), + peer('s3', 'me', cursor: {'page': 'p1', 'x': 0, 'y': 0}), + peer('s4', 'ben'), + ], + }); + expect(state.people.map((p) => p.uid), ['ana', 'ben']); + // Your own other window's cursor is not drawn either. + expect(state.cursorsOn('p1'), isEmpty); + }); + + test('messages about unknown peers change nothing', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [], + }); + expect( + identical( + applyPresenceMessage( + state, + {'t': 'cursor', 'sid': 'ghost', 'page': 'p', 'x': 1, 'y': 1}, + ), + state, + ), + isTrue, + ); + expect( + identical( + applyPresenceMessage(state, {'t': 'leave', 'sid': 'x'}), state), + isTrue, + ); + }); + + test('editing is set, changed, and cleared on one peer', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', cursor: {'page': 'p1', 'x': 1, 'y': 2}), + peer('s2', 'ben'), + ], + }); + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p1', + 'groups': ['g1', 'g2'], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g2', 'g1'}), + ); + // Editing leaves the cursor and the other peer alone. + expect( + state.peers['s1']!.cursor, + const PresenceCursor(pageId: 'p1', x: 1, y: 2), + ); + expect(state.peers['s2']!.editing, isNull); + + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p2', + 'groups': ['g3'], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p2', groupIds: {'g3'}), + ); + + // The room clears with no page and no groups. + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': null, + 'groups': [], + }); + expect(state.peers['s1']!.editing, isNull); + expect(state.peers['s1']!.cursor, isNotNull); + }); + + test('editing from an unknown peer changes nothing', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana')], + }); + expect( + identical( + applyPresenceMessage(state, { + 't': 'editing', + 'sid': 'ghost', + 'page': 'p1', + 'groups': ['g1'], + }), + state, + ), + isTrue, + ); + }); + + test('welcome and join peers carry what they are editing', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', editing: { + 'page': 'p1', + 'groups': ['g1'], + }), + ], + }); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + + state = applyPresenceMessage(state, { + 't': 'join', + 'peer': peer('s2', 'ben', editing: { + 'page': 'p2', + 'groups': ['g2', 'g3'], + }), + }); + expect( + state.peers['s2']!.editing, + const PresenceEditing(pageId: 'p2', groupIds: {'g2', 'g3'}), + ); + }); + + test('a repeat join keeps what the peer is editing', () { + var state = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana')], + }); + state = applyPresenceMessage(state, { + 't': 'editing', + 'sid': 's1', + 'page': 'p1', + 'groups': ['g1'], + }); + // A profile refresh, sent without editing. + state = + applyPresenceMessage(state, {'t': 'join', 'peer': peer('s1', 'ana')}); + expect( + state.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + }); + + test('malformed editing reads as nothing', () { + final malformed = [ + 'p1', + ['g1'], + { + 'groups': ['g1'] + }, + { + 'page': 3, + 'groups': ['g1'] + }, + {'page': 'p1'}, + {'page': 'p1', 'groups': 'g1'}, + {'page': 'p1', 'groups': []}, + { + 'page': 'p1', + 'groups': [1, '', null], + }, + ]; + for (final editing in malformed) { + final welcomed = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [peer('s1', 'ana', editing: editing)], + }); + expect(welcomed.peers['s1'], isNotNull, reason: '$editing'); + expect(welcomed.peers['s1']!.editing, isNull, reason: '$editing'); + + if (editing is! Map) continue; + final edited = applyPresenceMessage( + applyPresenceMessage(welcomed, { + 't': 'editing', + 'sid': 's1', + 'page': 'p9', + 'groups': ['g9'], + }), + {'t': 'editing', 'sid': 's1', ...editing}, + ); + expect(edited.peers['s1']!.editing, isNull, reason: '$editing'); + } + + // Unreadable group ids are dropped; readable ones stay. + final mixed = applyPresenceMessage(empty, { + 't': 'welcome', + 'uid': 'me', + 'peers': [ + peer('s1', 'ana', editing: { + 'page': 'p1', + 'groups': ['g1', 2, ''], + }), + ], + }); + expect( + mixed.peers['s1']!.editing, + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + }); + + test('a welcome from a room older than editing still parses', () { + final state = applyPresenceMessage(empty, { + 't': 'welcome', + 'self': 's-me', + 'uid': 'me', + 'peers': [ + { + 'sid': 's1', + 'uid': 'ana', + 'name': 'Ana', + 'avatar': null, + 'role': 'editor', + 'cursor': {'page': 'p1', 'x': 1, 'y': 2}, + }, + { + 'sid': 's2', + 'uid': 'ben', + 'name': 'Ben', + 'avatar': 'https://example.test/ben.png', + 'role': 'viewer', + 'cursor': null, + }, + ], + }); + expect(state.connection, PresenceConnection.live); + expect(state.peers.keys, ['s1', 's2']); + expect(state.peers.values.map((peer) => peer.editing), [null, null]); + expect(state.peopleOn('p1').single.uid, 'ana'); + expect(state.editingLineupGroup('p1', 'g1'), isEmpty); + }); + }); + + group('PresenceRoomState.editingLineupGroup', () { + PresencePeer editor( + String sid, + String uid, { + String pageId = 'p1', + Set groups = const {'g1'}, + }) => + PresencePeer( + sid: sid, + uid: uid, + name: 'Name $uid', + avatarUrl: null, + role: 'editor', + cursor: null, + editing: PresenceEditing(pageId: pageId, groupIds: groups), + ); + + test('never names yourself, even from another window', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'me'), + 's2': editor('s2', 'ana'), + }, + ); + expect(state.editingLineupGroup('p1', 'g1').map((peer) => peer.uid), + ['ana']); + }); + + test('names one person in two windows once', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'ana'), + 's2': editor('s2', 'ana', groups: {'g1', 'g2'}), + 's3': editor('s3', 'ben', groups: {'g2'}), + }, + ); + expect( + state.editingLineupGroup('p1', 'g1').map((peer) => peer.sid), ['s1']); + expect(state.editingLineupGroup('p1', 'g2').map((peer) => peer.uid), + ['ana', 'ben']); + }); + + test('a group on another page does not match', () { + final state = PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': editor('s1', 'ana', pageId: 'p2'), + 's2': PresencePeer( + sid: 's2', + uid: 'ben', + name: 'Ben', + avatarUrl: null, + role: 'editor', + cursor: const PresenceCursor(pageId: 'p1', x: 0, y: 0), + ), + }, + ); + expect(state.editingLineupGroup('p1', 'g1'), isEmpty); + expect(state.editingLineupGroup('p2', 'g1').single.uid, 'ana'); + expect(state.editingLineupGroup('p2', 'g2'), isEmpty); + }); + }); + + group('PresenceRoom', () { + late List<_FakeSocket> sockets; + late int passesIssued; + + RoomPass pass() { + passesIssued++; + return RoomPass( + url: Uri.parse('wss://presence.test/v1/rooms/strat'), + pass: 'pass-$passesIssued', + expiresAt: DateTime.now().add(const Duration(minutes: 2)), + ); + } + + PresenceRoom room({Future Function()? issuePass}) { + return PresenceRoom( + issuePass: issuePass ?? () async => pass(), + connect: (uri) { + final socket = _FakeSocket(uri); + sockets.add(socket); + return socket; + }, + ); + } + + setUp(() { + sockets = []; + passesIssued = 0; + }); + + void welcome(FakeAsync async, _FakeSocket socket) { + socket + .receive({'t': 'welcome', 'self': 's-me', 'uid': 'me', 'peers': []}); + async.flushMicrotasks(); + } + + test('joins with the pass in the query and goes live on welcome', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + expect(presence.state.connection, PresenceConnection.connecting); + expect(sockets.single.uri.queryParameters['pass'], 'pass-1'); + welcome(async, sockets.single); + expect(presence.state.connection, PresenceConnection.live); + presence.dispose(); + }); + }); + + test('is off when the deployment has no presence service', () { + fakeAsync((async) { + final presence = room(issuePass: () async => null)..start(); + async.flushMicrotasks(); + expect(presence.state.connection, PresenceConnection.off); + expect(sockets, isEmpty); + presence.dispose(); + }); + }); + + test('sends at most one cursor per interval, and always the last', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + for (var x = 0; x < 5; x++) { + presence + .moveCursor(PresenceCursor(pageId: 'p1', x: x.toDouble(), y: 0)); + async.elapse(const Duration(milliseconds: 5)); + } + expect(sockets.single.sentCursors, [0.0]); + async.elapse(const Duration(milliseconds: 50)); + expect(sockets.single.sentCursors, [0.0, 4.0]); + + presence.hideCursor(); + expect(sockets.single.sent.last, {'t': 'hide'}); + // Nothing to hide twice. + presence.hideCursor(); + expect(sockets.single.sent.where((m) => m is Map && m['t'] == 'hide'), + hasLength(1)); + presence.dispose(); + }); + }); + + test('rejoins after a drop and sends the cursor again', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.first); + presence.moveCursor(const PresenceCursor(pageId: 'p1', x: 7, y: 8)); + expect(sockets.first.sentCursors, [7.0]); + + sockets.first.drop(1006); + async.flushMicrotasks(); + expect(presence.state.connection, PresenceConnection.connecting); + expect(presence.state.peers, isEmpty); + + async.elapse(const Duration(seconds: 1)); + expect(sockets, hasLength(2)); + expect(sockets.last.uri.queryParameters['pass'], 'pass-2'); + welcome(async, sockets.last); + async.flushMicrotasks(); + expect(presence.state.connection, PresenceConnection.live); + expect(sockets.last.sentCursors, [7.0]); + presence.dispose(); + }); + }); + + test('backs off while passes cannot be issued', () { + fakeAsync((async) { + var failing = true; + final presence = room(issuePass: () async { + if (failing) throw StateError('offline'); + return pass(); + }) + ..start(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 1)); // retry 1 fails + async.elapse(const Duration(seconds: 2)); // retry 2 fails + expect(sockets, isEmpty); + failing = false; + async.elapse(const Duration(seconds: 4)); + expect(sockets, hasLength(1)); + presence.dispose(); + }); + }); + + test('renews the pass halfway through its life', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + for (var second = 0; second < 61; second++) { + async.elapse(const Duration(seconds: 1)); + sockets.single.pong(); + async.flushMicrotasks(); + } + expect( + sockets.single.sent, + anyElement(equals({'t': 'renew', 'pass': 'pass-2'})), + ); + presence.dispose(); + }); + }); + + test('an idle connection that stops answering is replaced', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + // Pings go out; no pong ever comes back. + async.elapse(const Duration(seconds: 61)); + expect(sockets.first.sent, contains('ping')); + expect(sockets.first.closed, isTrue); + async.elapse(const Duration(seconds: 1)); + expect(sockets, hasLength(2)); + presence.dispose(); + }); + }); + + PresenceEditing editing(String pageId, Set groups) => + PresenceEditing(pageId: pageId, groupIds: groups); + const interval = Duration(milliseconds: 150); + + test('sends what this user edits, sorted, and at most eight groups', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + expect(presence.editingInterval, interval); + + presence.setEditing(editing('p1', {'g2', 'g1'})); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p1', + 'groups': ['g1', 'g2'], + }); + + // The room ignores a message naming more than eight. + async.elapse(interval); + presence.setEditing(editing('p1', { + for (var i = 9; i >= 0; i--) 'g$i', + })); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p1', + 'groups': ['g0', 'g1', 'g2', 'g3', 'g4', 'g5', 'g6', 'g7'], + }); + expect(sockets.single.sentEditing, hasLength(2)); + presence.dispose(); + }); + }); + + test('sends at most one editing per interval, and always the last', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p1', {'g2'})); + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p2', {'g3'})); + expect(sockets.single.sentEditing, hasLength(1)); + + async.elapse(const Duration(milliseconds: 129)); + expect(sockets.single.sentEditing, hasLength(1)); + async.elapse(const Duration(milliseconds: 1)); + expect(sockets.single.sentEditing.last, { + 't': 'editing', + 'page': 'p2', + 'groups': ['g3'], + }); + final at = sockets.single.sentEditingAt; + expect(at[1].difference(at[0]), interval); + + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(2)); + + // There and back within the interval: the room already has it. + presence.setEditing(editing('p2', {'g4'})); + expect(sockets.single.sentEditing, hasLength(3)); + presence.setEditing(editing('p2', {'g5'})); + presence.setEditing(editing('p2', {'g4'})); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(3)); + presence.dispose(); + }); + }); + + test('clearing sends no groups for the page it last sent', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(interval); + presence.setEditing(editing('p2', {'g2'})); + async.elapse(interval); + presence.setEditing(null); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p2', + 'groups': [], + }); + + // A clear that waits out the interval still names the page. + async.elapse(interval); + presence.setEditing(editing('p3', {'g3'})); + presence.setEditing(null); + async.elapse(interval); + expect(sockets.single.sentEditing, hasLength(5)); + expect(sockets.single.sent.last, { + 't': 'editing', + 'page': 'p3', + 'groups': [], + }); + + // Something that never went out needs no clear. + presence.setEditing(editing('p4', {'g4'})); + presence.setEditing(null); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(5)); + presence.dispose(); + }); + }); + + test('sends nothing when what this user edits is unchanged', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.single); + + // Nothing edited, nothing to clear. + presence.setEditing(null); + async.elapse(interval); + expect(sockets.single.sentEditing, isEmpty); + + presence.setEditing(editing('p1', {'g1', 'g2'})); + async.elapse(interval); + presence.setEditing(editing('p1', {'g2', 'g1'})); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(1)); + + presence.setEditing(null); + async.elapse(interval); + presence.setEditing(null); + async.elapse(const Duration(seconds: 1)); + expect(sockets.single.sentEditing, hasLength(2)); + presence.dispose(); + }); + }); + + test('rejoins after a drop and sends what this user edits again', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.first); + presence.setEditing(editing('p1', {'g1'})); + expect(sockets.first.sentEditing, hasLength(1)); + + sockets.first.drop(1006); + async.flushMicrotasks(); + // Changed while away: only the latest goes out after the rejoin. + presence.setEditing(editing('p1', {'g2'})); + async.elapse(interval); + expect(sockets.first.sentEditing, hasLength(1)); + + async.elapse(const Duration(seconds: 1)); + expect(sockets, hasLength(2)); + expect(sockets.last.sentEditing, isEmpty); + welcome(async, sockets.last); + expect(sockets.last.sentEditing, [ + { + 't': 'editing', + 'page': 'p1', + 'groups': ['g2'], + }, + ]); + async.elapse(const Duration(seconds: 1)); + expect(sockets.last.sentEditing, hasLength(1)); + + // Stopped while away: the new room never heard of it, so nothing + // goes out. + sockets.last.drop(1006); + async.flushMicrotasks(); + presence.setEditing(null); + async.elapse(const Duration(seconds: 2)); + expect(sockets, hasLength(3)); + welcome(async, sockets.last); + async.elapse(const Duration(seconds: 1)); + expect(sockets.last.sentEditing, isEmpty); + presence.dispose(); + }); + }); + + test('a quick rejoin keeps editing messages an interval apart', () { + fakeAsync((async) { + final presence = room()..start(); + async.flushMicrotasks(); + welcome(async, sockets.first); + // Long enough that a lapsed pass rejoins at once. + async.elapse(const Duration(seconds: 11)); + + presence.setEditing(editing('p1', {'g1'})); + async.elapse(const Duration(milliseconds: 10)); + // Waits for the interval. + presence.setEditing(editing('p1', {'g2'})); + sockets.first.drop(closePassExpired); + async.flushMicrotasks(); + expect(sockets, hasLength(2)); + + async.elapse(const Duration(milliseconds: 90)); + welcome(async, sockets.last); // resends g2 + async.elapse(const Duration(milliseconds: 10)); + presence.setEditing(editing('p1', {'g3'})); + async.elapse(const Duration(seconds: 1)); + + expect(sockets.last.sentEditing.last['groups'], ['g3']); + // The room drops an editing message sent within 100 ms of the last, + // so the last state would never reach it. + final at = sockets.last.sentEditingAt; + for (var i = 1; i < at.length; i++) { + expect( + at[i].difference(at[i - 1]), + greaterThanOrEqualTo(presence.editingInterval), + reason: 'editing message $i of ${sockets.last.sentEditing}', + ); + } + presence.dispose(); + }); + }); + }); +} + +class _FakeSocket extends StreamChannelMixin + implements WebSocketChannel { + _FakeSocket(this.uri); + + final Uri uri; + final _incoming = StreamController(); + final List sent = []; + + /// When each of [sent] went out, by the (fake) clock. + final List sentAt = []; + bool closed = false; + int? _closeCode; + + List get sentCursors => [ + for (final message in sent) + if (message is Map && message['t'] == 'cursor') + (message['x'] as num).toDouble(), + ]; + + List> get sentEditing => [ + for (final message in sent) + if (message is Map && message['t'] == 'editing') message, + ]; + + /// When each of [sentEditing] went out. + List get sentEditingAt => [ + for (final (index, message) in sent.indexed) + if (message is Map && message['t'] == 'editing') sentAt[index], + ]; + + void receive(Map message) => + _incoming.add(jsonEncode(message)); + + void pong() => _incoming.add('pong'); + + void drop(int code) { + _closeCode = code; + _incoming.close(); + } + + @override + Stream get stream => _incoming.stream; + + @override + late final WebSocketSink sink = _FakeSink(this); + + @override + Future get ready => Future.value(); + + @override + int? get closeCode => _closeCode; + + @override + String? get closeReason => null; + + @override + String? get protocol => null; +} + +class _FakeSink implements WebSocketSink { + _FakeSink(this._socket); + + final _FakeSocket _socket; + + @override + void add(dynamic data) { + final text = data as String; + _socket.sent.add(text == 'ping' ? text : jsonDecode(text)); + _socket.sentAt.add(clock.now()); + } + + @override + Future close([int? closeCode, String? closeReason]) async { + _socket.closed = true; + } + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream stream) => stream.forEach(add); + + @override + Future get done => Future.value(); +} diff --git a/test/collab/referenced_asset_ids_test.dart b/test/collab/referenced_asset_ids_test.dart new file mode 100644 index 00000000..c9ebafd5 --- /dev/null +++ b/test/collab/referenced_asset_ids_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +void main() { + final asked = [for (var i = 0; i < 250; i += 1) 'image-$i']; + + test('asks about images a bounded batch at a time', () async { + final transport = + _ReferencesTransport({'image-3', 'image-120', 'image-249'}); + final repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + + final referenced = await repository.fetchReferencedAssetIds( + 'strategy-a', + [...asked, 'image-3'], + ); + + expect(referenced, {'image-3', 'image-120', 'image-249'}); + expect(transport.batchSizes, [100, 100, 50]); + }); + + test( + 'full snapshots are read as a client that can take one without the ' + "trash's pages", () async { + final transport = _RecordingTransport(); + final repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + + await expectLater( + repository.fetchFullSnapshot('strategy-a'), throwsStateError); + + final (name, args) = transport.calls.single; + expect(name, 'strategy:getFullSnapshot'); + expect(args.value['acceptsTrashedPagesLeftOut'], isA()); + expect((args.value['acceptsTrashedPagesLeftOut'] as ConvexBoolean).value, + isTrue); + }); + + test('cannot tell if any batch cannot', () async { + final transport = _ReferencesTransport({'image-3'}, nullBatch: 1); + final repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + + expect( + await repository.fetchReferencedAssetIds('strategy-a', asked), isNull); + }); +} + +/// A server whose content shows [referenced]; the batch numbered [nullBatch] +/// is answered as before the reference backfill. +final class _ReferencesTransport implements ConvexTransport { + _ReferencesTransport(this.referenced, {this.nullBatch}); + + final Set referenced; + final int? nullBatch; + final batchSizes = []; + + @override + Future query(String name, ConvexObject args) async { + expect(name, 'images:listReferencedAssetIds'); + final ids = [ + for (final id in (args.value['assetPublicIds'] as ConvexArray).value) + (id as ConvexString).value, + ]; + batchSizes.add(ids.length); + if (batchSizes.length - 1 == nullBatch) return const ConvexNull(); + return ConvexArray([ + for (final id in ids) + if (referenced.contains(id)) ConvexString(id), + ]); + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +/// Records the arguments of each query, then fails it. +final class _RecordingTransport implements ConvexTransport { + final calls = <(String, ConvexObject)>[]; + + @override + Future query(String name, ConvexObject args) async { + calls.add((name, args)); + throw StateError('not answered in this test'); + } + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} diff --git a/test/collab_sync_models_test.dart b/test/collab_sync_models_test.dart new file mode 100644 index 00000000..65e975c3 --- /dev/null +++ b/test/collab_sync_models_test.dart @@ -0,0 +1,632 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/json_converters.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; + +void main() { + group('CloudCollabModeState', () { + test('is enabled only for authenticated, ready users', () { + const mode = CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); + expect( + mode.isCloudEnabled( + isAuthenticated: true, + isConvexUserReady: true, + ), + isTrue, + ); + expect( + mode.isCloudEnabled( + isAuthenticated: false, + isConvexUserReady: true, + ), + isFalse, + ); + }); + + test('force-local fallback wins over auth', () { + const mode = CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: true, + ); + expect( + mode.isCloudEnabled( + isAuthenticated: true, + isConvexUserReady: true, + ), + isFalse, + ); + }); + }); + + group('StrategyOp protocol', () { + test('serializes record revision without a global sequence', () { + const op = PageContentPatchOp( + opId: 'op-1', + pagePublicId: 'page-1', + settings: {}, + expectedPageContentRevision: 4, + ); + + final json = op.toConvexJson(); + expect(currentCloudProtocolVersion, 5); + expect(json['type'], 'pageContent.patch'); + expect(json['expectedPageContentRevision'], 4); + expect(json.containsKey('expectedSequence'), isFalse); + expect(StrategyOp.fromJson(json), isA()); + }); + + test('withOpId changes identity without changing typed intent', () { + const original = LineupPatchOp( + opId: 'op-2', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + expectedLineupRevision: 3, + ); + final updated = original.withOpId('op-3'); + expect(updated.opId, 'op-3'); + expect(updated.entityPublicId, original.entityPublicId); + expect(updated.expectedRevision, 3); + expect(updated.type, StrategyOpType.lineupPatch); + }); + + test('every operation emits only its protocol-v3 fields', () { + const payload = {'value': 1}; + const ops = [ + StrategyPatchOp( + opId: '1', + payload: payload, + expectedStrategyRevision: 1, + ), + PageAddOp( + opId: '2', + pagePublicId: 'page', + payload: payload, + sortIndex: 2, + expectedStrategyRevision: 1, + ), + PagePatchOp( + opId: '3', + pagePublicId: 'page', + payload: payload, + expectedPageRevision: 3, + ), + PageDeleteOp( + opId: '4', + pagePublicId: 'page', + expectedStrategyRevision: 4, + ), + PageReorderOp( + opId: '5', + pagePublicId: 'page', + sortIndex: 5, + expectedStrategyRevision: 4, + ), + PageContentPatchOp( + opId: '6', + pagePublicId: 'page', + settings: payload, + expectedPageContentRevision: 6, + ), + ElementAddOp( + opId: '7', + elementPublicId: 'element', + pagePublicId: 'page', + payload: payload, + sortIndex: 7, + ), + ElementPatchOp( + opId: '8', + elementPublicId: 'element', + pagePublicId: 'other-page', + payload: payload, + sortIndex: 8, + expectedElementRevision: 8, + ), + ElementDeleteOp( + opId: '9', + elementPublicId: 'element', + pagePublicId: 'page', + expectedElementRevision: 9, + ), + ElementReorderOp( + opId: '10', + elementPublicId: 'element', + pagePublicId: 'page', + sortIndex: 10, + expectedElementRevision: 10, + ), + LineupAddOp( + opId: '11', + lineupPublicId: 'lineup', + pagePublicId: 'page', + payload: payload, + sortIndex: 11, + ), + LineupPatchOp( + opId: '12', + lineupPublicId: 'lineup', + pagePublicId: 'other-page', + payload: payload, + sortIndex: 12, + expectedLineupRevision: 12, + ), + LineupDeleteOp( + opId: '13', + lineupPublicId: 'lineup', + pagePublicId: 'page', + expectedLineupRevision: 13, + ), + LineupReorderOp( + opId: '14', + lineupPublicId: 'lineup', + pagePublicId: 'page', + sortIndex: 14, + expectedLineupRevision: 14, + ), + ]; + const expected = >[ + { + 'opId': '1', + 'type': 'strategy.patch', + 'payload': payload, + 'expectedStrategyRevision': 1, + }, + { + 'opId': '2', + 'type': 'page.add', + 'pagePublicId': 'page', + 'payload': payload, + 'sortIndex': 2, + 'expectedStrategyRevision': 1, + }, + { + 'opId': '3', + 'type': 'page.patch', + 'pagePublicId': 'page', + 'payload': payload, + 'expectedPageRevision': 3, + }, + { + 'opId': '4', + 'type': 'page.delete', + 'pagePublicId': 'page', + 'expectedStrategyRevision': 4, + }, + { + 'opId': '5', + 'type': 'page.reorder', + 'pagePublicId': 'page', + 'sortIndex': 5, + 'expectedStrategyRevision': 4, + }, + { + 'opId': '6', + 'type': 'pageContent.patch', + 'pagePublicId': 'page', + 'settings': payload, + 'expectedPageContentRevision': 6, + }, + { + 'opId': '7', + 'type': 'element.add', + 'elementPublicId': 'element', + 'pagePublicId': 'page', + 'payload': payload, + 'sortIndex': 7, + }, + { + 'opId': '8', + 'type': 'element.patch', + 'elementPublicId': 'element', + 'pagePublicId': 'other-page', + 'payload': payload, + 'sortIndex': 8, + 'expectedElementRevision': 8, + }, + { + 'opId': '9', + 'type': 'element.delete', + 'elementPublicId': 'element', + 'pagePublicId': 'page', + 'expectedElementRevision': 9, + }, + { + 'opId': '10', + 'type': 'element.reorder', + 'elementPublicId': 'element', + 'pagePublicId': 'page', + 'sortIndex': 10, + 'expectedElementRevision': 10, + }, + { + 'opId': '11', + 'type': 'lineup.add', + 'lineupPublicId': 'lineup', + 'pagePublicId': 'page', + 'payload': payload, + 'sortIndex': 11, + }, + { + 'opId': '12', + 'type': 'lineup.patch', + 'lineupPublicId': 'lineup', + 'pagePublicId': 'other-page', + 'payload': payload, + 'sortIndex': 12, + 'expectedLineupRevision': 12, + }, + { + 'opId': '13', + 'type': 'lineup.delete', + 'lineupPublicId': 'lineup', + 'pagePublicId': 'page', + 'expectedLineupRevision': 13, + }, + { + 'opId': '14', + 'type': 'lineup.reorder', + 'lineupPublicId': 'lineup', + 'pagePublicId': 'page', + 'sortIndex': 14, + 'expectedLineupRevision': 14, + }, + ]; + + for (var index = 0; index < ops.length; index += 1) { + final json = ops[index].toConvexJson(); + expect(json, expected[index]); + expect(json, isNot(contains('kind'))); + expect(json, isNot(contains('entityType'))); + expect(StrategyOp.fromJson(json).toConvexJson(), json); + } + }); + }); + + group('closed operation results', () { + test('decodes applied, noop, rejected, and failed variants', () { + expect( + OpAck.fromJson({ + 'opId': 'applied', + 'status': 'applied', + 'appliedRevision': 2, + }), + isA(), + ); + expect( + OpAck.fromJson({ + 'opId': 'noop', + 'status': 'noop', + 'currentRevision': 3, + }), + isA(), + ); + final rejected = OpAck.fromJson({ + 'opId': 'rejected', + 'status': 'rejected', + 'reason': 'revision_mismatch', + 'current': { + 'type': 'page', + 'revision': 4, + 'value': {'name': 'Current', 'isAttack': true, 'sortIndex': 0}, + }, + }); + expect(rejected, isA()); + expect(rejected.latestRevision, 4); + expect(rejected.latestPayload?['name'], 'Current'); + final failed = OpAck.fromJson({ + 'opId': 'failed', + 'status': 'failed', + 'code': 'INTERNAL_ERROR', + 'rawCode': 'NEW_SERVER_CODE', + 'message': 'Server failed safely', + }); + expect(failed, isA()); + expect((failed as FailedOpAck).rawCode, 'NEW_SERVER_CODE'); + expect(failed.isAck, isFalse); + }); + + test('decodes every closed rejection reason', () { + for (final reason in OpRejectionReason.values) { + final result = OpAck.fromJson({ + 'opId': reason.name, + 'status': 'rejected', + 'reason': reason.wireName, + }); + expect((result as RejectedOpAck).rejectionReason, reason); + } + }); + + test('rejects unknown result and current-snapshot discriminators', () { + expect( + () => OpAck.fromJson({'opId': 'x', 'status': 'future'}), + throwsFormatException, + ); + expect( + () => OpAck.fromJson({ + 'opId': 'x', + 'status': 'rejected', + 'reason': 'revision_mismatch', + 'current': {'type': 'future', 'revision': 1, 'value': {}}, + }), + throwsFormatException, + ); + }); + }); + + test('canonical JSON ignores key order and numeric representation', () { + final left = { + 'data': { + 'position': {'dx': 10, 'dy': 20.0}, + }, + }; + final right = { + 'data': { + 'position': {'dy': 20, 'dx': 10.0}, + }, + }; + expect(cloudJsonEquivalent(left, right), isTrue); + }); + + test('RemoteElement decodes the canonical data envelope', () { + const remote = RemoteElement( + publicId: 'el-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'agent', + payload: { + 'kind': 'agent', + 'payloadVersion': 1, + 'data': {'id': 'agent-1'}, + }, + sortIndex: 0, + revision: 1, + deleted: false, + ); + expect(remote.decodedPayload()['id'], 'agent-1'); + }); + + test('cloud payload data normalizes nested bridge maps for lineup parsing', + () { + final payload = { + 'kind': cloudLineupsPayloadKind, + 'payloadVersion': 1, + 'data': { + 'id': 'group-1', + 'origins': [ + { + 'id': 'origin-1', + 'agent': { + 'id': 'agent-1', + 'type': 'sova', + 'position': {'dx': 10, 'dy': 20}, + 'lineUpID': 'origin-1', + }, + }, + ], + 'landings': [ + { + 'id': 'landing-1', + 'ability': { + 'id': 'ability-1', + 'data': {'type': 'sova', 'index': 2.0}, + 'position': {'dx': 30, 'dy': 40}, + 'lineUpID': 'landing-1', + }, + }, + ], + 'links': [ + { + 'id': 'lineup-1', + 'originId': 'origin-1', + 'landingId': 'landing-1', + 'name': 'Garage smoke', + }, + ], + }, + }; + + final read = lineUpGraphFromCloudRows([ + CloudLineupRow(publicId: 'group-1', payload: payload), + ]); + final graph = read.graph; + final landing = graph.landings.single; + + expect(graph.links.single.name, 'Garage smoke'); + expect(graph.origins.single.agent.lineUpID, 'origin-1'); + expect(landing.id, 'landing-1'); + expect(landing.ability.lineUpID, 'landing-1'); + expect(landing.ability.position, const Offset(30, 40)); + expect(read.groupOf['lineup-1'], 'group-1'); + }); + + test('ability info accepts Convex float64 integers and rejects fractions', + () { + const converter = AbilityInfoConverter(); + + expect( + converter.fromJson({ + 'type': 'sova', + 'index': 2.0, + }).index, + 2, + ); + expect( + () => converter.fromJson({ + 'type': 'sova', + 'index': 2.5, + }), + throwsFormatException, + ); + }); + + test('cloud export fails closed instead of dropping a malformed lineup', () { + final now = DateTime.utc(2026, 8, 29); + const strategyId = 'strategy-1'; + const pageId = 'page-1'; + final snapshot = RemoteFullStrategySnapshot( + header: RemoteStrategyHeader( + publicId: strategyId, + name: 'Cloud strategy', + mapData: 'ascent', + revision: 1, + createdAt: now, + updatedAt: now, + ), + pages: [ + RemoteFullPage( + page: RemotePage( + publicId: pageId, + strategyPublicId: strategyId, + name: 'Page 1', + sortIndex: 0, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ), + content: RemotePageContent( + revision: 1, + createdAt: now, + updatedAt: now, + ), + ), + ], + elementsByPage: const >{}, + lineupsByPage: const >{ + pageId: [ + RemoteLineup( + publicId: 'lineup-1', + strategyPublicId: strategyId, + pagePublicId: pageId, + payload: {'invalid': true}, + sortIndex: 0, + revision: 1, + deleted: false, + ), + ], + }, + assetsById: const {}, + ); + + expect( + () => + StrategyImportExportService.strategyDataFromRemoteSnapshot(snapshot), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Cloud lineup group lineup-1 could not be read'), + ), + ), + ); + }); + + group('separate remote read models', () { + final header = RemoteStrategyHeader( + publicId: 'strat-1', + name: 'Cloud', + mapData: 'ascent', + revision: 3, + createdAt: DateTime.fromMillisecondsSinceEpoch(1), + updatedAt: DateTime.fromMillisecondsSinceEpoch(2), + themeOverridePalette: const {'base': '#111111'}, + ); + final page = RemotePage( + publicId: 'page-1', + strategyPublicId: 'strat-1', + name: 'Page 1', + sortIndex: 0, + isAttack: true, + revision: 2, + createdAt: DateTime.fromMillisecondsSinceEpoch(1), + updatedAt: DateTime.fromMillisecondsSinceEpoch(2), + ); + final content = RemotePageContent( + settings: const {'agentSize': 35.0}, + revision: 7, + createdAt: DateTime.fromMillisecondsSinceEpoch(1), + updatedAt: DateTime.fromMillisecondsSinceEpoch(2), + ); + + test('shell carries descriptors but no page content', () { + final shell = RemoteStrategyShell(header: header, pages: [page]); + expect(shell.header.revision, 3); + expect(shell.pages.single.revision, 2); + expect( + shell.header.themeOverridePalette, containsPair('base', '#111111')); + }); + + test('editor carries exactly one active page body', () { + final editor = RemoteEditorSnapshot( + shell: RemoteStrategyShell(header: header, pages: [page]), + activePage: RemotePageSnapshot( + page: page, + content: content, + elements: const [], + lineups: const [], + assetsById: const {}, + ), + ); + expect( + editor.activePage!.content.settings, containsPair('agentSize', 35)); + expect(editor.elementsByPage.keys, ['page-1']); + }); + + test('full snapshot groups every page for one-shot export', () { + const element = RemoteElement( + publicId: 'el-1', + strategyPublicId: 'strat-1', + pagePublicId: 'page-1', + elementType: 'text', + payload: {'kind': 'text', 'payloadVersion': 1, 'data': {}}, + sortIndex: 1, + revision: 1, + deleted: false, + ); + final grouped = RemoteFullStrategySnapshot.groupElementsByPage( + const [element], + ); + expect(grouped['page-1'], const [element]); + }); + }); + + group('RemoteImageAsset', () { + test('retains typed R2 metadata', () { + final asset = RemoteImageAsset( + publicId: 'asset-1', + provider: 'r2', + uploadStatus: 'active', + fileExtension: '.png', + width: null, + height: null, + byteSize: 42, + uploadedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + url: 'https://media.example.com/asset-1.png', + legacyStoragePath: null, + ); + expect(asset.provider, 'r2'); + expect(asset.byteSize, 42); + }); + }); + + test('CloudImageUploadIntent retains typed upload headers', () { + final intent = CloudImageUploadIntent( + provider: 'r2', + uploadId: 'upload-1', + objectKey: 'strategies/s/images/a.png', + uploadUrl: 'https://example.invalid/key', + requiredHeaders: const {'Content-Type': 'image/png'}, + expiresAt: DateTime.fromMillisecondsSinceEpoch(1700000000000), + maxBytes: 1024, + ); + expect(intent.requiredHeaders['Content-Type'], 'image/png'); + expect(intent.maxBytes, 1024); + }); +} diff --git a/test/color_persistence_test.dart b/test/color_persistence_test.dart index 8fd012ac..e4329b4f 100644 --- a/test/color_persistence_test.dart +++ b/test/color_persistence_test.dart @@ -18,30 +18,12 @@ import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; -const int _testColorAdapterTypeId = 220; - -class _TestColorAdapter extends TypeAdapter { - @override - final typeId = _testColorAdapterTypeId; - - @override - Color read(BinaryReader reader) { - return Color((reader.read() as num).toInt()); - } - - @override - void write(BinaryWriter writer, Color obj) { - writer.write(obj.toARGB32()); - } -} +const int _legacyHiveColorWireTypeId = 232; void _ensureAdaptersRegistered() { if (!Hive.isAdapterRegistered(20)) { registerIcarusAdapters(Hive); } - if (!Hive.isAdapterRegistered(_testColorAdapterTypeId)) { - Hive.registerAdapter(_TestColorAdapter()); - } } Map _readAdapterFields( @@ -57,13 +39,21 @@ Map _readAdapterFields( }; } -BinaryReaderImpl _legacyFieldReader(Map fields) { +BinaryReaderImpl _legacyFieldReader( + Map fields, { + Set legacyColorFieldIndices = const {}, +}) { _ensureAdaptersRegistered(); final writer = BinaryWriterImpl(Hive)..writeByte(fields.length); for (final entry in fields.entries) { - writer - ..writeByte(entry.key) - ..write(entry.value); + writer.writeByte(entry.key); + if (legacyColorFieldIndices.contains(entry.key)) { + writer + ..writeTypeId(_legacyHiveColorWireTypeId) + ..writeInt((entry.value as Color).toARGB32()); + } else { + writer.write(entry.value); + } } return BinaryReaderImpl(Uint8List.fromList(writer.toBytes()), Hive); } @@ -72,6 +62,13 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('JSON color persistence', () { + test('Icarus bootstrap owns its Flutter value adapters', () { + _ensureAdaptersRegistered(); + + expect(Hive.isAdapterRegistered(200), isTrue); + expect(Hive.isAdapterRegistered(201), isTrue); + }); + test('strategy page exports user-editable colors as ints', () { final page = StrategyPage( id: 'page-1', @@ -264,21 +261,24 @@ void main() { test('drawing adapters still read legacy Hive Color payloads', () { final restoredLine = LineAdapter().read( - _legacyFieldReader({ - 0: const Offset(1, 2), - 1: const Offset(3, 4), - 2: const Color(0xFFEF4444), - 3: true, - 4: false, - 5: 'legacy-line', - 6: BoundingBox( - min: const Offset(1, 2), - max: const Offset(3, 4), - ), - 7: false, - 8: TraversalSpeedProfile.walking, - 9: 5.0, - }), + _legacyFieldReader( + { + 0: const Offset(1, 2), + 1: const Offset(3, 4), + 2: const Color(0xFFEF4444), + 3: true, + 4: false, + 5: 'legacy-line', + 6: BoundingBox( + min: const Offset(1, 2), + max: const Offset(3, 4), + ), + 7: false, + 8: TraversalSpeedProfile.walking, + 9: 5.0, + }, + legacyColorFieldIndices: const {2}, + ), ); expect(restoredLine.colorValue, 0xFFEF4444); @@ -306,6 +306,7 @@ void main() { scale: 200, fileExtension: '.png', tagColorValue: 0xFF3B82F6, + link: 'local-image.png', ), ), ); @@ -338,6 +339,8 @@ void main() { ); expect(textFields[5], 0xFF22C55E); + expect(imageFields[3], 'local-image.png'); + expect(imageFields[11], isNull); expect(imageFields[9], 0xFF3B82F6); expect(utilityFields[11], 0xFFF59E0B); expect(circleAgentFields[1], 0xFFA855F7); @@ -346,5 +349,22 @@ void main() { expect(utilityFields.values.whereType(), isEmpty); expect(circleAgentFields.values.whereType(), isEmpty); }); + + test('legacy placed images default a missing local link', () { + final restored = PlacedImageAdapter().read( + _legacyFieldReader({ + 1: 1.5, + 2: 200.0, + 4: 'legacy-image', + 5: false, + 6: const Offset(3, 4), + 8: '.png', + 9: 0xFF3B82F6, + 10: worldSizedMediaVersion, + }), + ); + + expect(restored.link, isEmpty); + }); }); } diff --git a/test/convex_architecture_test.dart b/test/convex_architecture_test.dart new file mode 100644 index 00000000..45e74211 --- /dev/null +++ b/test/convex_architecture_test.dart @@ -0,0 +1,138 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final repositoryRoot = Directory.current; + + test('generated Convex API stays inside the collaboration boundary', () { + final violations = []; + for (final file in _dartFiles(repositoryRoot, 'lib')) { + final relativePath = _relativePath(repositoryRoot, file); + if (relativePath.startsWith('lib/collab/')) continue; + final source = file.readAsStringSync(); + if (source.contains('collab/generated/') || + source.contains('IcarusConvexApi') || + source.contains('ConvexOptional<')) { + violations.add(relativePath); + } + } + + expect( + violations, + isEmpty, + reason: + 'Generated client imports and types may only appear in lib/collab.', + ); + }); + + test('collaboration data layer does not import Flutter UI libraries', () { + final violations = []; + for (final file in _dartFiles(repositoryRoot, 'lib/collab')) { + final source = file.readAsStringSync(); + if (source.contains("package:flutter/material.dart") || + source.contains("package:flutter/widgets.dart")) { + violations.add(_relativePath(repositoryRoot, file)); + } + } + + expect( + violations, + isEmpty, + reason: 'The collaboration data layer must not import Flutter UI.', + ); + }); + + test('public route strings stay in generated or transport code', () { + final spec = jsonDecode( + File('${repositoryRoot.path}/convex/function_spec.json') + .readAsStringSync(), + ) as Map; + final routes = (spec['functions'] as List) + .whereType() + .where((entry) => (entry['visibility'] as Map?)?['kind'] == 'public') + .map((entry) => entry['identifier'] as String) + .map((identifier) => identifier.replaceFirst('.js:', ':')) + .toSet(); + final violations = []; + + for (final file in _dartFiles(repositoryRoot, 'lib')) { + final relativePath = _relativePath(repositoryRoot, file); + if (relativePath.startsWith('lib/collab/generated/') || + relativePath.startsWith('lib/collab/transport/') || + relativePath.startsWith('lib/collab/src/')) { + continue; + } + final source = file.readAsStringSync(); + for (final route in routes) { + final quotedRoute = RegExp("['\"]${RegExp.escape(route)}['\"]"); + if (quotedRoute.hasMatch(source)) { + violations.add('$relativePath: $route'); + } + } + } + + expect( + violations, + isEmpty, + reason: 'Call generated modules instead of spelling Convex routes.', + ); + }); + + test('legacy JSON repository boundary stays deleted', () { + final collabSource = _dartFiles(repositoryRoot, 'lib/collab') + .map((file) => file.readAsStringSync()) + .join('\n'); + for (final legacySymbol in const [ + '_decodeJsonPayload', + '_decodeObjectList', + 'watchFoldersForParent', + 'class CloudFolderSummary', + 'class CloudStrategySummary', + 'factory CloudFolderSummary.fromJson', + 'factory CloudStrategySummary.fromJson', + 'factory CloudImageUploadIntent.fromJson', + 'factory RemoteStrategyHeader.fromJson', + 'factory RemotePage.fromJson', + 'factory RemotePageContent.fromJson', + 'factory RemoteElement.fromJson', + 'factory RemoteLineup.fromJson', + 'factory RemoteImageAsset.fromJson', + ]) { + expect(collabSource, isNot(contains(legacySymbol)), reason: legacySymbol); + } + }); + + test('raw Convex client calls stay inside platform adapters', () { + final violations = []; + final directCall = RegExp( + r'\b(?:ConvexClient\.instance|_client|client)\s*\.\s*' + r'(?:query|mutation|action|subscribe)\s*\(', + ); + for (final file in _dartFiles(repositoryRoot, 'lib')) { + final relativePath = _relativePath(repositoryRoot, file); + if (relativePath.startsWith('lib/collab/src/')) continue; + if (directCall.hasMatch(file.readAsStringSync())) { + violations.add(relativePath); + } + } + + expect( + violations, + isEmpty, + reason: 'Raw calls belong only in the native and web adapters.', + ); + }); +} + +Iterable _dartFiles(Directory root, String relativeDirectory) { + return Directory('${root.path}/$relativeDirectory') + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')); +} + +String _relativePath(Directory root, File file) { + return file.path.substring(root.path.length + 1).replaceAll('\\', '/'); +} diff --git a/test/convex_connection_provider_test.dart b/test/convex_connection_provider_test.dart new file mode 100644 index 00000000..70660cfb --- /dev/null +++ b/test/convex_connection_provider_test.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; + +void main() { + test('the connection snapshot follows the connection after its first read', + () async { + // On web the socket is still opening when the app starts. A snapshot + // that kept its first read said offline forever, so the op queue never + // sent an edit. + final changes = StreamController(); + addTearDown(changes.close); + final container = ProviderContainer(overrides: [ + convexConnectionProvider.overrideWith((ref) => changes.stream), + ]); + addTearDown(container.dispose); + container.listen(convexConnectionProvider, (_, __) {}); + + changes.add(false); + await Future.delayed(Duration.zero); + expect(container.read(convexConnectionSnapshotProvider), isFalse); + + changes.add(true); + await Future.delayed(Duration.zero); + expect(container.read(convexConnectionSnapshotProvider), isTrue); + + changes.add(false); + await Future.delayed(Duration.zero); + expect(container.read(convexConnectionSnapshotProvider), isFalse); + }); + + group('redactSyncDiagnosticText', () { + test('drops the query of a presigned upload URL', () { + const error = 'ClientException: Failed to fetch, uri=https://abc.r2.' + 'cloudflarestorage.com/icarus-media/strategies/s/images/i/1.png' + '?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=key%2F2026' + '&X-Amz-Signature=deadbeef'; + + final redacted = redactSyncDiagnosticText(error); + + expect( + redacted, + 'ClientException: Failed to fetch, uri=https://abc.r2.' + 'cloudflarestorage.com/icarus-media/strategies/s/images/i/1.png' + '?', + ); + }); + + test('hides token assignments outside URLs', () { + expect( + redactSyncDiagnosticText('failed access_token=abc.def refresh_token: ' + 'xyz X-Amz-Signature=beef'), + 'failed access_token= refresh_token: ' + 'X-Amz-Signature=', + ); + }); + + test('leaves ordinary errors readable', () { + expect( + redactSyncDiagnosticText(StateError('Cloud connection is offline.')), + 'Bad state: Cloud connection is offline.', + ); + expect( + redactSyncDiagnosticText('see https://example.com/docs for help'), + 'see https://example.com/docs for help', + ); + }); + }); +} diff --git a/test/convex_generated_client_test.dart b/test/convex_generated_client_test.dart new file mode 100644 index 00000000..1efa2b83 --- /dev/null +++ b/test/convex_generated_client_test.dart @@ -0,0 +1,375 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_payload_codecs.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/src/convex_client_types.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/collab/transport/convex_transport_adapter.dart'; + +void main() { + test('generated query fetch and watch decode the same typed folder tree', + () async { + final transport = _FakeTransport(result: _folderTreeValue()); + final api = IcarusConvexApi(transport); + final query = api.folders.listTree( + scope: const ConvexOptional.present(FoldersListTreeArgsScope.all), + ); + + final fetched = await query.fetch(); + final watched = await query.watch().first; + + expect(fetched.single.publicId, 'folder-1'); + expect(watched.single.role, FoldersListTreeResultItemRole.owner); + expect(transport.lastName, 'folders:listTree'); + expect( + (transport.lastArgs!.value['scope'] as ConvexString).value, + 'all', + ); + }); + + test('generated decoder reports the full endpoint and field path', () { + final invalid = _folderTreeValue(role: 'future-role'); + expect( + () => decodeFoldersListTreeResult(invalid), + throwsA( + isA().having( + (error) => error.path, + 'path', + 'folders.js:listTree.returns[0].role', + ), + ), + ); + }); + + test('unknown server error code and structured data are preserved', () async { + final data = ConvexObject({'retryable': const ConvexBoolean(true)}); + final api = IcarusConvexApi( + _FakeTransport( + result: const ConvexNull(), + error: ConvexTransportError( + rawCode: 'FUTURE_SERVER_CODE', + message: 'Try again later', + data: data, + ), + ), + ); + + await expectLater( + api.health.ping().fetch(), + throwsA( + isA() + .having((error) => error.code, 'code', ConvexErrorCode.unknown) + .having( + (error) => error.rawCode, + 'rawCode', + 'FUTURE_SERVER_CODE', + ) + .having((error) => error.data, 'data', same(data)), + ), + ); + }); + + test('normalized Convex values round-trip fixed-seed nested values', () { + final random = Random(0x1ca405); + for (var index = 0; index < 250; index += 1) { + final source = _randomJsonValue(random, 4); + final decoded = ConvexValue.fromDart(source).toDart(); + expect(jsonEncode(decoded), jsonEncode(source), + reason: 'seed item $index'); + } + + final bytes = Uint8List.fromList([0, 1, 127, 128, 255]); + final decodedBytes = ConvexValue.fromDart(bytes).toDart() as Uint8List; + expect(decodedBytes, bytes); + expect( + (ConvexValue.fromDart(double.nan).toDart() as double).isNaN, + isTrue, + ); + expect( + ConvexValue.fromDart(double.infinity).toDart(), + double.infinity, + ); + }); + + test('annotated payload codecs enforce their exact server tag', () { + final payload = cloudElementPayload( + kind: 'agent', + data: { + 'id': 'agent-1', + 'position': [12.5, 8.0], + }, + ); + const codec = AgentConvexCodec(); + + expect(codec.decode(codec.encode(payload)), payload); + expect( + () => codec.encode({...payload, 'kind': 'drawing'}), + throwsFormatException, + ); + }); + + test('platform transport normalizes native and web value shapes identically', + () async { + final raw = { + 'nullValue': null, + 'integerValue': 9223372036854775807, + 'bigintValue': BigInt.parse('-9223372036854775808'), + 'floatValue': -0.0, + 'bytesValue': Uint8List.fromList([0, 127, 255]), + 'nested': [ + true, + {'present': 'yes'}, + ], + }; + final nativeLike = PlatformConvexTransport( + _ScriptedValueSource(result: raw), + ); + final webLike = PlatformConvexTransport( + _ScriptedValueSource(result: Map.from(raw)), + ); + + final nativeValue = await nativeLike.query( + 'fixture:values', + ConvexObject(const {}), + ); + final webValue = await webLike.query( + 'fixture:values', + ConvexObject(const {}), + ); + + expect(_describeConvexValue(nativeValue), _describeConvexValue(webValue)); + final object = nativeValue as ConvexObject; + expect(object.value.containsKey('omitted'), isFalse); + expect( + (object.value['bytesValue'] as ConvexBytes).value, + Uint8List.fromList([0, 127, 255]), + ); + expect( + (object.value['floatValue'] as ConvexFloat).value.isNegative, + isTrue, + ); + }); + + test('recoverable function error does not close a generated watch', () async { + final source = _ScriptedValueSource( + result: _folderTreeValue().toDart(), + subscriptionEvents: [ + _folderTreeValue(name: 'Before error').toDart(), + const ConvexClientFunctionError( + rawCode: 'CONFLICT', + message: 'retryable conflict', + data: {'code': 'CONFLICT', 'revision': 4}, + ), + _folderTreeValue(name: 'After error').toDart(), + ], + ); + final api = IcarusConvexApi(PlatformConvexTransport(source)); + final names = []; + final errors = []; + final receivedSecondValue = Completer(); + final subscription = api.folders.listTree().watch().listen( + (folders) { + names.add(folders.single.name); + if (names.length == 2) receivedSecondValue.complete(); + }, + onError: (Object error) => errors.add(error), + ); + + await receivedSecondValue.future.timeout(const Duration(seconds: 2)); + await subscription.cancel(); + + expect(names, ['Before error', 'After error']); + expect(errors, [isA()]); + final error = errors.single as ConvexFunctionException; + expect(error.code, ConvexErrorCode.conflict); + expect(error.rawCode, 'CONFLICT'); + expect(source.handle.cancelled, isTrue); + }); + + test('normalization failure closes a generated watch', () async { + final source = _ScriptedValueSource( + result: _folderTreeValue().toDart(), + subscriptionEvents: [DateTime.utc(2026), _folderTreeValue().toDart()], + ); + final api = IcarusConvexApi(PlatformConvexTransport(source)); + final errors = []; + final done = Completer(); + + api.folders.listTree().watch().listen( + (_) => fail('A contract-failed watch must not emit another value'), + onError: (Object error) => errors.add(error), + onDone: done.complete, + ); + await done.future.timeout(const Duration(seconds: 2)); + + expect(errors, [isA()]); + expect(source.handle.cancelled, isTrue); + }); +} + +ConvexArray _folderTreeValue({ + String role = 'owner', + String name = 'Defaults', +}) => + ConvexArray([ + ConvexObject({ + 'color': const ConvexNull(), + 'createdAt': const ConvexInteger(1), + 'customColorValue': const ConvexNull(), + 'iconCodePoint': const ConvexNull(), + 'iconFontFamily': const ConvexNull(), + 'iconFontPackage': const ConvexNull(), + 'iconId': const ConvexNull(), + 'name': ConvexString(name), + 'parentFolderPublicId': const ConvexNull(), + 'publicId': const ConvexString('folder-1'), + 'role': ConvexString(role), + 'updatedAt': const ConvexInteger(2), + 'strategyCount': const ConvexInteger(0), + 'mapPeeks': ConvexArray(const []), + 'agentTypes': ConvexArray(const []), + }), + ]); + +Object? _describeConvexValue(ConvexValue value) => switch (value) { + ConvexNull() => 'null', + ConvexBoolean(:final value) => ['boolean', value], + ConvexInteger(:final value) => ['integer', value], + ConvexFloat(:final value) => [ + 'float', + value == 0 && value.isNegative ? '-0' : value, + ], + ConvexBigInt(:final value) => ['bigint', value.toString()], + ConvexString(:final value) => ['string', value], + ConvexBytes(:final value) => ['bytes', ...value], + ConvexArray(:final value) => [ + 'array', + ...value.map(_describeConvexValue), + ], + ConvexObject(:final value) => { + 'object': { + for (final entry in value.entries) + entry.key: _describeConvexValue(entry.value), + }, + }, + }; + +Object? _randomJsonValue(Random random, int depth) { + if (depth == 0) { + return switch (random.nextInt(4)) { + 0 => null, + 1 => random.nextBool(), + 2 => random.nextInt(100000), + _ => 'value-${random.nextInt(100000)}', + }; + } + return switch (random.nextInt(6)) { + 0 => null, + 1 => random.nextBool(), + 2 => random.nextInt(100000), + 3 => random.nextDouble() * 1000, + 4 => [ + for (var index = 0; index < random.nextInt(5); index += 1) + _randomJsonValue(random, depth - 1), + ], + _ => { + for (var index = 0; index < random.nextInt(5); index += 1) + 'key-$index': _randomJsonValue(random, depth - 1), + }, + }; +} + +final class _FakeTransport implements ConvexTransport { + _FakeTransport({required this.result, this.error}); + + final ConvexValue result; + final ConvexTransportError? error; + String? lastName; + ConvexObject? lastArgs; + + Future _respond(String name, ConvexObject args) async { + lastName = name; + lastArgs = args; + final failure = error; + if (failure != null) throw failure; + return result; + } + + @override + Future action(String name, ConvexObject args) => + _respond(name, args); + + @override + Future mutation(String name, ConvexObject args) => + _respond(name, args); + + @override + Future query(String name, ConvexObject args) => + _respond(name, args); + + @override + Stream subscribe(String name, ConvexObject args) async* { + yield await _respond(name, args); + } +} + +final class _ScriptedValueSource implements ConvexClientValueSource { + _ScriptedValueSource({ + required this.result, + this.subscriptionEvents = const [], + }); + + final Object? result; + final List subscriptionEvents; + final _TestSubscriptionHandle handle = _TestSubscriptionHandle(); + + @override + Future actionValue({ + required String name, + required Map args, + }) async => + result; + + @override + Future mutationValue({ + required String name, + required Map args, + }) async => + result; + + @override + Future queryValue(String name, Map args) async => + result; + + @override + Future subscribeValue({ + required String name, + required Map args, + required void Function(Object? value) onUpdate, + required void Function(ConvexClientFunctionError error) onError, + }) async { + scheduleMicrotask(() { + for (final event in subscriptionEvents) { + if (handle.cancelled) return; + if (event is ConvexClientFunctionError) { + onError(event); + } else { + onUpdate(event); + } + } + }); + return handle; + } +} + +final class _TestSubscriptionHandle implements SubscriptionHandle { + bool cancelled = false; + + @override + void cancel() => cancelled = true; +} diff --git a/test/custom_shape_color_test.dart b/test/custom_shape_color_test.dart index 7c17b56e..9e09fa65 100644 --- a/test/custom_shape_color_test.dart +++ b/test/custom_shape_color_test.dart @@ -9,7 +9,7 @@ import 'package:icarus/providers/utility_provider.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'shape-color-test', diff --git a/test/custom_shape_indicator_test.dart b/test/custom_shape_indicator_test.dart index 77a11d1e..d21e64fe 100644 --- a/test/custom_shape_indicator_test.dart +++ b/test/custom_shape_indicator_test.dart @@ -16,13 +16,13 @@ import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_circle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/custom_rectangle_utility_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_circle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/placed_custom_rectangle_widget.dart'; import 'package:icarus/widgets/draggable_widgets/utilities/shape_indicator_fade.dart'; import 'package:icarus/widgets/mouse_watch.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; class _FixedMapProvider extends MapProvider { @override diff --git a/test/delete_menu_focus_test.dart b/test/delete_menu_focus_test.dart new file mode 100644 index 00000000..13daef7c --- /dev/null +++ b/test/delete_menu_focus_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/delete_area.dart'; +import 'package:icarus/widgets/global_shortcuts.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _LocalStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'local-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + + @override + void setUnsaved() {} +} + +/// The editor's delete button inside the app's shortcut layer, with one +/// agent placed. +Future _pumpEditor(WidgetTester tester) async { + final container = ProviderContainer( + overrides: [strategyProvider.overrideWith(_LocalStrategy.new)], + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + home: const Scaffold( + body: Align(alignment: Alignment.topRight, child: DeleteArea()), + ), + builder: (context, child) => GlobalShortcuts(child: child!), + ), + ), + ); + container.read(agentProvider.notifier).addAgent( + PlacedAgent( + id: 'jett', + type: AgentType.jett, + position: const Offset(10, 20), + ), + ); + return container; +} + +Future _pressUndo(WidgetTester tester) async { + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.keyZ); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); +} + +void main() { + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + testWidgets('Ctrl+Z undoes a clear made from the delete menu', + (tester) async { + final container = await _pumpEditor(tester); + + await tester.tap(find.byIcon(LucideIcons.trash2)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.user)); + await tester.pumpAndSettle(); + expect(container.read(agentProvider), isEmpty); + // A click on an empty part of the page. + await tester.tapAt(const Offset(200, 500)); + await tester.pumpAndSettle(); + + await _pressUndo(tester); + expect(container.read(agentProvider).map((agent) => agent.id), ['jett']); + }); + + testWidgets('shortcuts fire while the shortcut layer itself has focus', + (tester) async { + final container = await _pumpEditor(tester); + // With the web semantics tree on, a click on an empty part of the page + // focuses the app's shortcut node itself. + final shortcutNode = FocusManager.instance.rootScope.descendants + .singleWhere((node) => node.debugLabel == 'global-shortcuts'); + shortcutNode.requestFocus(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus, shortcutNode); + + await _pressUndo(tester); + expect(container.read(agentProvider), isEmpty); + }); +} diff --git a/test/desktop_4_6_3_adapters_test_support.dart b/test/desktop_4_6_3_adapters_test_support.dart new file mode 100644 index 00000000..939eec4c --- /dev/null +++ b/test/desktop_4_6_3_adapters_test_support.dart @@ -0,0 +1,523 @@ +// Desktop 4.6.3's adapters for the typeIds this build writes by hand, copied +// verbatim from the desktop-stable-v4.6.3+103 tag (only the class names +// changed). Every other adapter is unchanged since that release. Rollback +// tests decode with these to prove 4.6.3 opens, and keeps, what this build +// saves. Never edit them to match this build. + +// ignore_for_file: deprecated_member_use_from_same_package + +import 'package:flutter/material.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/hive/hive_adapters.dart' + show + ellipseDrawingAdapterTypeId, + freeDrawingAdapterTypeId, + lineAdapterTypeId, + rectangleDrawingAdapterTypeId; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +class Desktop463PlacedAgentAdapter extends TypeAdapter { + @override + final typeId = 2; + + @override + PlacedAgent read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PlacedAgent( + type: fields[0] as AgentType, + position: fields[4] as Offset, + id: fields[2] as String, + isAlly: fields[1] == null ? true : fields[1] as bool, + lineUpID: fields[5] as String?, + state: fields[6] == null ? AgentState.none : fields[6] as AgentState, + weapon: fields[7] == null ? WeaponType.none : fields[7] as WeaponType, + )..isDeleted = fields[3] as bool; + } + + @override + void write(BinaryWriter writer, PlacedAgent obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.isAlly) + ..writeByte(2) + ..write(obj.id) + ..writeByte(3) + ..write(obj.isDeleted) + ..writeByte(4) + ..write(obj.position) + ..writeByte(5) + ..write(obj.lineUpID) + ..writeByte(6) + ..write(obj.state) + ..writeByte(7) + ..write(obj.weapon); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Desktop463PlacedAgentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class Desktop463PlacedAbilityAdapter extends TypeAdapter { + @override + final typeId = 3; + + @override + PlacedAbility read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PlacedAbility( + data: fields[0] as AbilityInfo, + position: fields[5] as Offset, + id: fields[3] as String, + isAlly: fields[1] == null ? true : fields[1] as bool, + length: fields[6] == null ? 0 : (fields[6] as num).toDouble(), + lineUpID: fields[7] as String?, + rotation: fields[2] == null ? 0 : (fields[2] as num).toDouble(), + visualState: fields[9] == null + ? const AbilityVisualState() + : fields[9] as AbilityVisualState, + armLengthsMeters: (fields[8] as List?)?.cast(), + )..isDeleted = fields[4] as bool; + } + + @override + void write(BinaryWriter writer, PlacedAbility obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.data) + ..writeByte(1) + ..write(obj.isAlly) + ..writeByte(2) + ..write(obj.rotation) + ..writeByte(3) + ..write(obj.id) + ..writeByte(4) + ..write(obj.isDeleted) + ..writeByte(5) + ..write(obj.position) + ..writeByte(6) + ..write(obj.length) + ..writeByte(7) + ..write(obj.lineUpID) + ..writeByte(8) + ..write(obj.armLengthsMeters) + ..writeByte(9) + ..write(obj.visualState); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Desktop463PlacedAbilityAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class Desktop463StrategyPageAdapter extends TypeAdapter { + @override + final typeId = 20; + + @override + StrategyPage read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return StrategyPage( + id: fields[0] as String, + name: fields[2] as String, + isAutoNamed: fields[14] as bool?, + drawingData: (fields[3] as List).cast(), + agentData: (fields[4] as List).cast(), + abilityData: (fields[5] as List).cast(), + textData: (fields[6] as List).cast(), + imageData: (fields[7] as List).cast(), + utilityData: (fields[8] as List).cast(), + sortIndex: (fields[1] as num).toInt(), + isAttack: fields[9] as bool, + settings: fields[10] as StrategySettings, + lineUpOrigins: fields[15] == null + ? const [] + : (fields[15] as List).cast(), + lineUpLandings: fields[16] == null + ? const [] + : (fields[16] as List).cast(), + lineUpLinks: fields[17] == null + ? const [] + : (fields[17] as List).cast(), + lineUpGroups: fields[12] == null + ? const [] + : (fields[12] as List).cast(), + lineUps: + fields[11] == null ? const [] : (fields[11] as List).cast(), + ); + } + + @override + void write(BinaryWriter writer, StrategyPage obj) { + writer + ..writeByte(17) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.sortIndex) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.drawingData) + ..writeByte(4) + ..write(obj.agentData) + ..writeByte(5) + ..write(obj.abilityData) + ..writeByte(6) + ..write(obj.textData) + ..writeByte(7) + ..write(obj.imageData) + ..writeByte(8) + ..write(obj.utilityData) + ..writeByte(9) + ..write(obj.isAttack) + ..writeByte(10) + ..write(obj.settings) + ..writeByte(11) + ..write(obj.lineUps) + ..writeByte(12) + ..write(obj.lineUpGroups) + ..writeByte(14) + ..write(obj.isAutoNamed) + ..writeByte(15) + ..write(obj.lineUpOrigins) + ..writeByte(16) + ..write(obj.lineUpLandings) + ..writeByte(17) + ..write(obj.lineUpLinks); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Desktop463StrategyPageAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class Desktop463FolderAdapter extends TypeAdapter { + @override + final typeId = 17; + + @override + Folder read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (var i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Folder( + name: fields[0] as String, + id: fields[1] as String, + parentID: fields[2] as String?, + dateCreated: fields[3] as DateTime, + iconId: FolderIconRegistry.idForStoredValue(fields[4]), + color: fields[5] as FolderColor? ?? FolderColor.red, + customColor: switch (fields[6]) { + final int colorValue => Color(colorValue), + final Color color => color, + null => null, + _ => null, + }, + ); + } + + @override + void write(BinaryWriter writer, Folder obj) { + writer + ..writeByte(7) + ..writeByte(0) + ..write(obj.name) + ..writeByte(1) + ..write(obj.id) + ..writeByte(2) + ..write(obj.parentID) + ..writeByte(3) + ..write(obj.dateCreated) + ..writeByte(4) + ..write(obj.iconId) + ..writeByte(5) + ..write(obj.color) + ..writeByte(6) + ..write(obj.customColor?.toARGB32()); + } +} + +class Desktop463FreeDrawingAdapter extends TypeAdapter { + @override + final typeId = freeDrawingAdapterTypeId; + + @override + FreeDrawing read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + + return FreeDrawing( + listOfPoints: (fields[0] as List?)?.cast(), + colorValue: _readDrawingHiveColorValue( + fields, + colorFieldIndex: 2, + legacyColorValueFieldIndex: 12, + ), + thickness: fields[11] == null + ? Settings.defaultStrokeThickness + : (fields[11] as num).toDouble(), + boundingBox: fields[6] as BoundingBox?, + isDotted: fields[3] as bool, + hasArrow: fields[4] as bool, + id: fields[5] as String, + showTraversalTime: fields[8] == null ? false : fields[8] as bool, + traversalSpeedProfile: fields[9] == null + ? TraversalSpeed.defaultProfile + : fields[9] as TraversalSpeedProfile, + cachedPolylineLengthUnits: (fields[10] as num?)?.toDouble(), + ); + } + + @override + void write(BinaryWriter writer, FreeDrawing obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.listOfPoints) + ..writeByte(2) + ..write(obj.colorValue) + ..writeByte(3) + ..write(obj.isDotted) + ..writeByte(4) + ..write(obj.hasArrow) + ..writeByte(5) + ..write(obj.id) + ..writeByte(6) + ..write(obj.boundingBox) + ..writeByte(8) + ..write(obj.showTraversalTime) + ..writeByte(9) + ..write(obj.traversalSpeedProfile) + ..writeByte(10) + ..write(obj.cachedPolylineLengthUnits) + ..writeByte(11) + ..write(obj.thickness); + } +} + +class Desktop463LineAdapter extends TypeAdapter { + @override + final typeId = lineAdapterTypeId; + + @override + Line read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + + return Line( + lineStart: fields[0] as Offset, + lineEnd: fields[1] as Offset, + colorValue: _readDrawingHiveColorValue( + fields, + colorFieldIndex: 2, + legacyColorValueFieldIndex: 10, + ), + thickness: fields[9] == null + ? Settings.defaultStrokeThickness + : (fields[9] as num).toDouble(), + boundingBox: fields[6] as BoundingBox?, + isDotted: fields[3] as bool, + hasArrow: fields[4] as bool, + id: fields[5] as String, + showTraversalTime: fields[7] == null ? false : fields[7] as bool, + traversalSpeedProfile: fields[8] == null + ? TraversalSpeed.defaultProfile + : fields[8] as TraversalSpeedProfile, + ); + } + + @override + void write(BinaryWriter writer, Line obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.lineStart) + ..writeByte(1) + ..write(obj.lineEnd) + ..writeByte(2) + ..write(obj.colorValue) + ..writeByte(3) + ..write(obj.isDotted) + ..writeByte(4) + ..write(obj.hasArrow) + ..writeByte(5) + ..write(obj.id) + ..writeByte(6) + ..write(obj.boundingBox) + ..writeByte(7) + ..write(obj.showTraversalTime) + ..writeByte(8) + ..write(obj.traversalSpeedProfile) + ..writeByte(9) + ..write(obj.thickness); + } +} + +class Desktop463RectangleDrawingAdapter extends TypeAdapter { + @override + final typeId = rectangleDrawingAdapterTypeId; + + @override + RectangleDrawing read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + + return RectangleDrawing( + start: fields[0] as Offset, + end: fields[1] as Offset, + colorValue: _readDrawingHiveColorValue( + fields, + colorFieldIndex: 2, + legacyColorValueFieldIndex: 8, + ), + thickness: fields[7] == null + ? Settings.defaultStrokeThickness + : (fields[7] as num).toDouble(), + boundingBox: fields[6] as BoundingBox?, + isDotted: fields[3] as bool, + hasArrow: fields[4] as bool, + id: fields[5] as String, + ); + } + + @override + void write(BinaryWriter writer, RectangleDrawing obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.start) + ..writeByte(1) + ..write(obj.end) + ..writeByte(2) + ..write(obj.colorValue) + ..writeByte(3) + ..write(obj.isDotted) + ..writeByte(4) + ..write(obj.hasArrow) + ..writeByte(5) + ..write(obj.id) + ..writeByte(6) + ..write(obj.boundingBox) + ..writeByte(7) + ..write(obj.thickness); + } +} + +class Desktop463EllipseDrawingAdapter extends TypeAdapter { + @override + final typeId = ellipseDrawingAdapterTypeId; + + @override + EllipseDrawing read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + + return EllipseDrawing( + start: fields[0] as Offset, + end: fields[1] as Offset, + colorValue: _readDrawingHiveColorValue( + fields, + colorFieldIndex: 2, + legacyColorValueFieldIndex: 8, + ), + thickness: fields[3] == null + ? Settings.defaultStrokeThickness + : (fields[3] as num).toDouble(), + boundingBox: fields[7] as BoundingBox?, + isDotted: fields[4] as bool, + hasArrow: fields[5] as bool, + id: fields[6] as String, + ); + } + + @override + void write(BinaryWriter writer, EllipseDrawing obj) { + writer + ..writeByte(8) + ..writeByte(0) + ..write(obj.start) + ..writeByte(1) + ..write(obj.end) + ..writeByte(2) + ..write(obj.colorValue) + ..writeByte(3) + ..write(obj.thickness) + ..writeByte(4) + ..write(obj.isDotted) + ..writeByte(5) + ..write(obj.hasArrow) + ..writeByte(6) + ..write(obj.id) + ..writeByte(7) + ..write(obj.boundingBox); + } +} + +int _readDrawingHiveColorValue( + Map fields, { + required int colorFieldIndex, + int? legacyColorValueFieldIndex, +}) { + final value = legacyColorValueFieldIndex == null + ? fields[colorFieldIndex] + : fields[legacyColorValueFieldIndex] ?? fields[colorFieldIndex]; + + return switch (value) { + final int colorValue => colorValue, + final num colorValue => colorValue.toInt(), + final Color color => color.toARGB32(), + _ => 0xFFFFFFFF, + }; +} diff --git a/test/desktop_image_size_test.dart b/test/desktop_image_size_test.dart new file mode 100644 index 00000000..c1a753e9 --- /dev/null +++ b/test/desktop_image_size_test.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:path/path.dart' as path; + +class _OpenStrategy extends StrategyProvider { + _OpenStrategy(this.source); + + final StrategySource source; + + @override + StrategyState build() => StrategyState( + strategyId: 'strategy-1', + strategyName: 'Strategy', + source: source, + isOpen: true, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory supportDir; + + setUp(() async { + supportDir = await Directory.systemTemp.createTemp('icarus-image-size'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => supportDir.path, + ); + }); + + tearDown(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + await supportDir.delete(recursive: true); + }); + + ProviderContainer desktop(StrategySource source) { + final container = ProviderContainer(overrides: [ + imageFilesOnDeviceProvider.overrideWithValue(true), + strategyProvider.overrideWith(() => _OpenStrategy(source)), + ]); + addTearDown(container.dispose); + return container; + } + + File imageFile(String id) => + File(path.join(supportDir.path, 'strategy-1', 'images', '$id.png')); + + test( + 'a cloud strategy on desktop refuses an image over 15 MB before ' + 'writing it', () async { + final container = desktop(StrategySource.cloud); + + await expectLater( + container.read(placedImageProvider.notifier).saveSecureImage( + Uint8List(maxCloudImageBytes + 1), + 'too-large', + '.png', + strategyId: 'strategy-1', + ), + throwsA( + isA().having( + (error) => error.userMessage, + 'userMessage', + 'This image is over 15 MB. Pick a smaller one.', + ), + ), + ); + + expect(imageFile('too-large').existsSync(), isFalse); + }); + + test('a cloud strategy on desktop keeps an image at the limit', () async { + final container = desktop(StrategySource.cloud); + + await container.read(placedImageProvider.notifier).saveSecureImage( + Uint8List(maxCloudImageBytes), + 'at-limit', + '.png', + strategyId: 'strategy-1', + ); + + expect(imageFile('at-limit').lengthSync(), maxCloudImageBytes); + }); + + test('a local strategy never uploads, so it keeps any size', () async { + final container = desktop(StrategySource.local); + + await container.read(placedImageProvider.notifier).saveSecureImage( + Uint8List(maxCloudImageBytes + 1), + 'local-large', + '.png', + strategyId: 'strategy-1', + ); + + expect(imageFile('local-large').lengthSync(), maxCloudImageBytes + 1); + }); +} diff --git a/test/discord_presence_service_test.dart b/test/discord_presence_service_test.dart index dfc9ad5b..2df06797 100644 --- a/test/discord_presence_service_test.dart +++ b/test/discord_presence_service_test.dart @@ -8,7 +8,7 @@ void main() { group('DiscordPresenceData', () { test('uses a generic library presence when no strategy is open', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: true, stratName: null, id: 'testID', @@ -24,7 +24,7 @@ void main() { test('shares map and side without sharing the strategy name', () { final presence = DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'Secret tournament execute', id: 'strategy-id', @@ -46,7 +46,7 @@ void main() { test('summarizes what is on the board', () { DiscordPresenceData build({int agents = 0, int abilities = 0}) => DiscordPresenceData.fromAppState( - strategy: StrategyState( + strategy: const StrategyState( isSaved: false, stratName: 'A-site rush', id: 'strategy-id', diff --git a/test/drawing_merge_path_test.dart b/test/drawing_merge_path_test.dart new file mode 100644 index 00000000..1862cf51 --- /dev/null +++ b/test/drawing_merge_path_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/providers/drawing_provider.dart'; + +FreeDrawing _stroke(String id, List points) => FreeDrawing( + id: id, + listOfPoints: [...points], + color: Colors.white, + isDotted: false, + hasArrow: false, + ); + +void main() { + setUp(() => CoordinateSystem(playAreaSize: const Size(1200, 800))); + + test('a merge reuses the built path of a stroke whose points are unchanged', + () { + final container = ProviderContainer(); + addTearDown(container.dispose); + final drawings = container.read(drawingProvider.notifier); + const same = [Offset(10, 10), Offset(20, 30), Offset(40, 35)]; + drawings.fromHive([ + _stroke('same', same), + _stroke('moved', const [Offset(0, 0), Offset(5, 5)]), + ]); + final before = { + for (final d in container.read(drawingProvider).elements) + d.id: (d as FreeDrawing).path, + }; + + drawings.mergeRemote([ + _stroke('same', same), + _stroke('moved', const [Offset(0, 0), Offset(50, 50)]), + ], (_) => false); + + final after = { + for (final d in container.read(drawingProvider).elements) + d.id: (d as FreeDrawing).path, + }; + expect(identical(after['same'], before['same']), isTrue); + expect(identical(after['moved'], before['moved']), isFalse); + expect(after['moved']!.getBounds().right, + greaterThan(before['moved']!.getBounds().right)); + }); +} diff --git a/test/drawing_provider_test.dart b/test/drawing_provider_test.dart index f3ef1aea..56ba56c2 100644 --- a/test/drawing_provider_test.dart +++ b/test/drawing_provider_test.dart @@ -11,22 +11,23 @@ import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( - isSaved: true, - stratName: null, - id: 'drawing-test', + return const StrategyState( + strategyId: 'drawing-test', + strategyName: null, + source: StrategySource.local, storageDirectory: null, - activePageId: null, + isOpen: true, ); } @override void setUnsaved() { - state = state.copyWith(isSaved: false); + state = state.copyWith(isOpen: true); } } diff --git a/test/durable_cloud_media_outbox_version_test.dart b/test/durable_cloud_media_outbox_version_test.dart new file mode 100644 index 00000000..6685ab00 --- /dev/null +++ b/test/durable_cloud_media_outbox_version_test.dart @@ -0,0 +1,58 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/const/hive_boxes.dart'; + +void main() { + for (final marker in [null, 1]) { + test('legacy media marker $marker opens without deleting saved work', + () async { + final directory = + await Directory.systemTemp.createTemp('icarus-media-v1-'); + try { + Hive.init(directory.path); + final box = + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + if (marker != null) + await box.put(durableCloudMediaOutboxVersionKey, marker); + final legacy = { + 'outboxVersion': 1, + 'jobId': 'old-image', + 'assetPublicId': 'old-image', + 'uploadId': 'pending-upload' + }; + await box.put('old-image', legacy); + + await prepareDurableCloudMediaOutbox(); + await prepareDurableCloudMediaOutbox(); + + expect(box.get(durableCloudMediaOutboxVersionKey), 2); + expect(jsonEncode(box.get('old-image')), jsonEncode(legacy)); + final loaded = HiveDurableCloudMediaOutboxStore().load(); + expect(loaded.jobs, isEmpty); + expect(loaded.issues.single.storageKey, 'old-image'); + } finally { + await Hive.close(); + await directory.delete(recursive: true); + } + }); + } + + test('unknown future media versions are not restamped', () async { + final directory = + await Directory.systemTemp.createTemp('icarus-media-future-'); + try { + Hive.init(directory.path); + final box = await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + await box.put(durableCloudMediaOutboxVersionKey, 999); + await expectLater(prepareDurableCloudMediaOutbox(), throwsStateError); + expect(box.get(durableCloudMediaOutboxVersionKey), 999); + } finally { + await Hive.close(); + await directory.delete(recursive: true); + } + }); +} diff --git a/test/durable_strategy_outbox_version_test.dart b/test/durable_strategy_outbox_version_test.dart new file mode 100644 index 00000000..217e2dad --- /dev/null +++ b/test/durable_strategy_outbox_version_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; + +void main() { + test('version 1 outbox records migrate without deleting saved work', + () async { + for (final legacyMarker in [null, 1]) { + final directory = await Directory.systemTemp.createTemp( + 'icarus-outbox-v1-', + ); + try { + Hive.init(directory.path); + final box = await Hive.openBox( + HiveBoxNames.strategyOutboxBox, + ); + final record = _record(); + final legacyJson = record.toJson()..['outboxVersion'] = 1; + const invalidKey = 'unreadable-work'; + final invalidJson = { + 'outboxVersion': 1, + 'opId': 'broken', + }; + if (legacyMarker != null) { + await box.put(durableOutboxVersionKey, legacyMarker); + } + await box.put(record.storageKey, legacyJson); + await box.put(invalidKey, invalidJson); + + await prepareDurableStrategyOutbox(); + + expect( + box.get(durableOutboxVersionKey), + durableOutboxRecordVersion, + ); + expect( + (box.get(record.storageKey) as Map)['outboxVersion'], + durableOutboxRecordVersion, + ); + expect(jsonEncode(box.get(invalidKey)), jsonEncode(invalidJson)); + final loaded = HiveDurableStrategyOutboxStore().load(); + expect(loaded.records.single.pending.op.opId, 'pending-cloud-edit'); + expect(loaded.issues.single.storageKey, invalidKey); + + await prepareDurableStrategyOutbox(); + expect( + HiveDurableStrategyOutboxStore() + .load() + .records + .single + .pending + .op + .opId, + 'pending-cloud-edit', + ); + } finally { + await Hive.close(); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } + } + }); +} + +DurableOutboxRecord _record() { + const op = ElementPatchOp( + opId: 'pending-cloud-edit', + elementPublicId: 'element-one', + pagePublicId: 'page-one', + payload: {'value': 'keep-me'}, + expectedElementRevision: 1, + ); + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: const EntitySyncKey.element('page-one', 'element-one'), + pending: const PendingOp(op: op, clientId: 'client-a'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026, 9, 4), + updatedAt: DateTime.utc(2026, 9, 4), + ); +} diff --git a/test/editor_operation_scope_test.dart b/test/editor_operation_scope_test.dart new file mode 100644 index 00000000..2f56ebea --- /dev/null +++ b/test/editor_operation_scope_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/widgets/editor_operation_scope.dart'; + +void main() { + for (final cancel in [false, true]) { + testWidgets( + 'pointer ${cancel ? 'cancel' : 'up'} releases after gesture callbacks', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + var protectedDuringCommit = false; + var committed = false; + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: EditorOperationScope( + child: Directionality( + textDirection: TextDirection.ltr, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanUpdate: (_) {}, + onPanEnd: (_) { + protectedDuringCommit = + container.read(editorPointersProvider).isNotEmpty; + committed = true; + }, + onPanCancel: () { + protectedDuringCommit = + container.read(editorPointersProvider).isNotEmpty; + committed = true; + }, + child: const SizedBox.expand(), + ), + ), + ), + )); + final gesture = await tester.startGesture(const Offset(100, 100)); + await gesture.moveBy(const Offset(60, 0)); + expect(container.read(editorPointersProvider), isNotEmpty); + if (cancel) { + await gesture.cancel(); + } else { + await gesture.up(); + } + await tester.pump(); + expect(committed, isTrue); + expect(protectedDuringCommit, isTrue); + expect(container.read(editorPointersProvider), isEmpty); + }); + } + + testWidgets( + 'all pointers must finish and unmount releases remaining pointers', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const EditorOperationScope(child: SizedBox.expand()), + )); + final first = await tester.startGesture(const Offset(20, 20), pointer: 1); + final second = await tester.startGesture(const Offset(40, 40), pointer: 2); + expect(container.read(editorPointersProvider).length, 2); + await first.up(); + await tester.pump(); + expect(container.read(editorPointersProvider).keys, {2}); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + expect(container.read(editorPointersProvider), isEmpty); + await second.cancel(); + }); + + testWidgets('hover from the same device releases a pointer whose up was lost', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const EditorOperationScope(child: SizedBox.expand()), + )); + tester.binding.handlePointerEvent(const PointerDownEvent( + pointer: 7, + device: 3, + kind: PointerDeviceKind.mouse, + position: Offset(20, 20), + )); + expect(container.read(editorPointersProvider).keys, {7}); + tester.binding.handlePointerEvent(const PointerHoverEvent( + device: 4, + kind: PointerDeviceKind.mouse, + position: Offset(30, 30), + )); + await tester.pump(); + expect(container.read(editorPointersProvider).keys, {7}); + tester.binding.handlePointerEvent(const PointerHoverEvent( + device: 3, + kind: PointerDeviceKind.mouse, + position: Offset(30, 30), + )); + await tester.pump(); + expect(container.read(editorPointersProvider), isEmpty); + }); + + testWidgets('a trackpad pan/zoom holds until it ends, even while hovering', + (tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const EditorOperationScope(child: SizedBox.expand()), + )); + tester.binding.handlePointerEvent(const PointerPanZoomStartEvent( + pointer: 9, + device: 5, + position: Offset(20, 20), + )); + expect(container.read(editorPointersProvider).keys, {9}); + tester.binding.handlePointerEvent(const PointerHoverEvent( + device: 5, + kind: PointerDeviceKind.mouse, + position: Offset(30, 30), + )); + await tester.pump(); + expect(container.read(editorPointersProvider).keys, {9}); + tester.binding.handlePointerEvent(const PointerPanZoomEndEvent( + pointer: 9, + device: 5, + position: Offset(20, 20), + )); + await tester.pump(); + expect(container.read(editorPointersProvider), isEmpty); + }); + + group('what a press holds', () { + Future pump(WidgetTester tester) async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: Directionality( + textDirection: TextDirection.ltr, + child: EditorOperationScope( + child: Row(children: [ + // The sidebar. + const SizedBox(width: 100, height: 400), + SizedBox( + width: 400, + height: 400, + child: EditorCanvasRegion( + child: Stack(children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (_) {}, + ), + ), + for (final (id, left) in [('a', 0.0), ('b', 200.0)]) + EditorEntityLayer( + key: ValueKey(id), + id: id, + child: Positioned( + left: left, + top: 0, + width: 50, + height: 50, + child: GestureDetector( + onPanStart: (_) {}, + // Items paint something that takes the hit. + child: const ColoredBox(color: Color(0xFF000000)), + ), + ), + ), + ]), + ), + ), + ]), + ), + ), + )); + return container; + } + + testWidgets('a press on an item holds that item only', (tester) async { + final container = await pump(tester); + final gesture = await tester.startGesture(const Offset(325, 125)); + expect(container.read(editorHeldEntitiesProvider), {'b'}); + await gesture.up(); + await tester.pump(); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + }); + + testWidgets('a press on empty canvas holds nothing', (tester) async { + final container = await pump(tester); + final gesture = await tester.startGesture(const Offset(250, 400)); + expect(container.read(editorPointersProvider), isNotEmpty); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + await gesture.up(); + }); + + testWidgets('a press off the canvas holds everything', (tester) async { + final container = await pump(tester); + final gesture = await tester.startGesture(const Offset(50, 150)); + expect(container.read(editorHeldEntitiesProvider), isNull); + await gesture.up(); + await tester.pump(); + expect(container.read(editorHeldEntitiesProvider), isEmpty); + }); + }); + + test('a copy made by a held drag is held with its source', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'source') + ..down(1) + ..holdAlongside('source', 'copy'); + expect(container.read(editorHeldEntitiesProvider), {'source', 'copy'}); + }); +} diff --git a/test/folder_content_empty_states_test.dart b/test/folder_content_empty_states_test.dart index 61860775..4b1a0d77 100644 --- a/test/folder_content_empty_states_test.dart +++ b/test/folder_content_empty_states_test.dart @@ -7,8 +7,9 @@ import 'package:hive_ce_flutter/hive_flutter.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/folder_content.dart'; @@ -39,6 +40,7 @@ void main() { Future pumpRoot(WidgetTester tester, VoidCallback onCreate) { return tester.pumpWidget( ProviderScope( + overrides: [authProvider.overrideWith(_SignedOutAuthProvider.new)], child: ShadApp( themeMode: ThemeMode.dark, darkTheme: ShadThemeData( @@ -67,7 +69,7 @@ void main() { expect(find.byType(FolderCard), findsOneWidget); expect(find.text('No strategies in this folder'), findsNothing); - expect(find.text('No strategies yet'), findsNothing); + expect(find.text('Your library is empty'), findsNothing); }); testWidgets('an empty view offers to create a strategy', (tester) async { @@ -75,8 +77,22 @@ void main() { await pumpRoot(tester, () => created++); await tester.pump(); - expect(find.text('No strategies yet'), findsOneWidget); - await tester.tap(find.byKey(const ValueKey('library-empty-new-strategy'))); + expect(find.text('Your library is empty'), findsOneWidget); + await tester.tap( + find.byKey(const ValueKey('library-empty-create-strategy')), + ); expect(created, 1); }); } + +/// Signed out: the library shows only what is on this computer. +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} diff --git a/test/folder_grid_alignment_test.dart b/test/folder_grid_alignment_test.dart index c73adbe2..56fa5c07 100644 --- a/test/folder_grid_alignment_test.dart +++ b/test/folder_grid_alignment_test.dart @@ -8,8 +8,9 @@ import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/widgets/folder_card.dart'; import 'package:icarus/widgets/folder_content.dart'; @@ -63,6 +64,7 @@ void main() { await tester.pumpWidget( ProviderScope( + overrides: [authProvider.overrideWith(_SignedOutAuthProvider.new)], child: ShadApp( themeMode: ThemeMode.dark, darkTheme: ShadThemeData( @@ -93,3 +95,15 @@ void main() { }); } } + +/// Signed out: the library shows only what is on this computer. +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} diff --git a/test/global_strategy_outbox_test.dart b/test/global_strategy_outbox_test.dart new file mode 100644 index 00000000..c7ce9831 --- /dev/null +++ b/test/global_strategy_outbox_test.dart @@ -0,0 +1,1259 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/in_app_debug_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + setUpAll(() { + appProviderContainer = ProviderContainer(); + }); + + tearDownAll(() { + appProviderContainer.dispose(); + }); + + test('restart drains current-account work across closed strategies', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-one', opId: 'one')); + await store.put(_record( + strategyId: 'strategy-two', + opId: 'two', + elementId: 'element-two', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.map((call) => call.strategyId).toSet(), { + 'strategy-one', + 'strategy-two', + }); + expect(store.values, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + }); + + test('background drain never submits another account work', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-a', opId: 'a')); + await store.put(_record( + accountId: 'account-b', + strategyId: 'strategy-b', + opId: 'b', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await _waitUntil(() => repository.calls.length == 1); + + expect(repository.calls.single.strategyId, 'strategy-a'); + expect(store.values, hasLength(1)); + expect( + container.read(strategyOpQueueProvider).accountOutbox.accountId, + 'account-a', + ); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isFalse, + ); + + notifier.setCurrentAccount('account-b'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, hasLength(1)); + + final accountBContainer = _container( + store: store, + repository: repository, + authAccountId: 'account-b', + ); + addTearDown(accountBContainer.dispose); + accountBContainer + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-b'); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.last.strategyId, 'strategy-b'); + expect(store.values, isEmpty); + }); + + test('a claim outlived by its account is not sent and stays with it', + () async { + final store = _HeldClaimStore(); + await store.put(_record(strategyId: 'strategy-a', opId: 'a')); + store.putStatuses.clear(); + final auth = _SwitchableAuthProvider('account-a'); + final repository = _RecordingRepository(); + final container = _container( + store: store, + repository: repository, + createAuth: () => auth, + ); + addTearDown(container.dispose); + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await store.claimStarted.future; + + // A signs out and B signs in while the claim is being saved. + auth.signInAs('account-b'); + await _waitUntil( + () => container.read(strategyOpQueueProvider).accountId == 'account-b', + ); + store.allowClaim(); + await _waitUntil(() => store.putStatuses.length == 2); + + expect(repository.calls, isEmpty); + expect(store.putStatuses, [ + DurableOutboxStatus.inFlight, + DurableOutboxStatus.queued, + ]); + final kept = store.load().records.single; + expect(kept.accountId, 'account-a'); + expect(kept.pending.op.opId, 'a'); + expect(kept.pending.clientId, 'client-strategy-a'); + expect(kept.pending.attempts, 0); + final queue = container.read(strategyOpQueueProvider); + expect(queue.accountOutbox.hasWork, isFalse); + expect(queue.pending, isEmpty); + + // A's next session sends the same op, bound to A. + auth.signInAs('account-a'); + await _waitUntil(() => repository.calls.length == 1); + expect(repository.calls.single.ops.single.opId, 'a'); + expect(repository.accountSubjects.single, 'account-a'); + await _waitUntil(() => store.values.isEmpty); + }); + + test('a batch refused under a later account stays queued for its own', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'strategy-a', opId: 'a')); + final auth = _SwitchableAuthProvider('account-a'); + final repository = _HeldRefusalRepository(); + final container = _container( + store: store, + repository: repository, + createAuth: () => auth, + ); + addTearDown(container.dispose); + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await repository.firstStarted.future; + expect(repository.accountSubjects.single, 'account-a'); + + // The transport delivers A's batch after B signs in; the server refuses + // it, since the batch is bound to A. + auth.signInAs('account-b'); + await _waitUntil( + () => container.read(strategyOpQueueProvider).accountId == 'account-b', + ); + repository.refuseFirst(); + await _waitUntil( + () => store.load().records.single.status == DurableOutboxStatus.queued, + ); + + final kept = store.load().records.single; + expect(kept.accountId, 'account-a'); + expect(kept.pending.op.opId, 'a'); + expect(kept.pending.clientId, 'client-strategy-a'); + final queue = container.read(strategyOpQueueProvider); + expect(queue.attentionByEntityKey, isEmpty); + expect(queue.accountOutbox.hasWork, isFalse); + expect(queue.lastError, isNull); + expect(repository.calls, hasLength(1)); + }); + + test('active work waits for a background request then runs next', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'closed-strategy', opId: 'closed')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active-strategy', accountId: 'account-a'); + await repository.firstStarted.future; + + await notifier.enqueue( + _op(opId: 'active', elementId: 'active-element'), + flushImmediately: false, + ); + await Future.delayed(const Duration(milliseconds: 220)); + expect(repository.calls, hasLength(1)); + + repository.releaseFirst(); + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls[0].strategyId, 'closed-strategy'); + expect(repository.calls[1].strategyId, 'active-strategy'); + expect(repository.calls[1].ops.single.opId, 'active'); + }); + + test('opening a draining strategy preserves a concurrent final intent', + () async { + final store = _BlockingSuccessorStore(); + await store.put(_record(strategyId: 'opening', opId: 'predecessor')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await repository.firstStarted.future; + + notifier.setActiveStrategy('opening', accountId: 'account-a'); + final enqueue = notifier.enqueue( + _op( + opId: 'final-intent', + elementId: 'element-one', + value: 'final', + ), + ); + await store.successorWriteStarted.future; + repository.releaseFirst(); + await Future.delayed(const Duration(milliseconds: 20)); + expect(repository.calls, hasLength(1)); + + store.allowSuccessorWrite.complete(); + await enqueue; + await _waitUntil(() => repository.calls.length == 2); + final finalOp = repository.calls.last.ops.single as ElementPatchOp; + expect(finalOp.payload, {'value': 'final'}); + expect(finalOp.expectedElementRevision, 2); + }); + + test('a lineup backing off does not hold up the drain', () async { + final store = MemoryDurableStrategyOutboxStore(); + final lineup = LineupAddOp( + opId: 'add-lineup', + lineupPublicId: 'k', + pagePublicId: 'page-one', + payload: cloudLineupsPayload({ + 'id': 'k', + 'origins': [ + {'id': 'o', 'agent': {}}, + ], + 'landings': [ + {'id': 'l', 'ability': {}}, + ], + 'links': [ + {'id': 'k', 'originId': 'o', 'landingId': 'l'}, + ], + }), + sortIndex: 0, + ); + // The oldest work is a lineup that just failed to send and is backing + // off. + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'blocked', + entityKey: EntitySyncKey.forStrategyOp(lineup)!, + pending: PendingOp( + op: lineup, + clientId: 'client-blocked', + attempts: 3, + lastAttemptAt: DateTime.now(), + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2025), + updatedAt: DateTime(2025), + )); + // The first drain after sign-in ignores backoff; this older work takes + // it, so the drains after it honour the lineup's backoff. + await store.put(_record(strategyId: 'first', opId: 'first') + .copyWith(updatedAt: DateTime(2024))); + await store.put(_record(strategyId: 'open', opId: 'open')); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length == 2); + expect(repository.calls.map((call) => call.strategyId), ['first', 'open']); + expect( + store.load().records.map((record) => record.pending.op.opId), + ['add-lineup'], + ); + }); + + test('one failed closed strategy does not block another', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'fails', opId: 'fails')); + await store.put(_record( + strategyId: 'lands', + opId: 'lands', + elementId: 'element-lands', + )); + final repository = _FailFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + await _waitUntil(() => repository.calls.length >= 2); + expect(repository.calls.take(2).map((call) => call.strategyId), [ + 'fails', + 'lands', + ]); + expect( + store.values.values + .map((value) => DurableOutboxRecord.fromJson( + Map.from(value as Map), + )) + .where((record) => record.strategyPublicId == 'fails'), + isNotEmpty, + ); + }); + + test('paused and rejected records remain visible and never auto-run', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'paused-strategy', + opId: 'paused', + status: DurableOutboxStatus.paused, + lastError: 'Retry limit reached', + )); + await store.put(_record( + strategyId: 'rejected-strategy', + opId: 'rejected', + elementId: 'rejected-element', + status: DurableOutboxStatus.attention, + lastError: 'Revision conflict', + )); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + final summary = container.read(strategyOpQueueProvider).accountOutbox; + expect(repository.calls, isEmpty); + expect(summary.strategyCount, 2); + expect(summary.needsAttention, isTrue); + expect( + summary.strategies['paused-strategy']!.reason, 'Retry limit reached'); + expect( + summary.strategies['rejected-strategy']!.reason, 'Revision conflict'); + }); + + test('inactive failed writes remain account-visible and unreliable', + () async { + final store = _FailingPutStore(); + final repository = _RecordingRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('failed-strategy', accountId: 'account-a'); + + await notifier.enqueue( + _op(opId: 'failed-write', elementId: 'failed-element'), + flushImmediately: false, + ); + notifier.setActiveStrategy('other-strategy', accountId: 'account-a'); + + final queue = container.read(strategyOpQueueProvider); + final failedSummary = queue.accountOutbox.strategies['failed-strategy']; + expect(queue.strategyPublicId, 'other-strategy'); + expect(queue.outboxIsReliable, isFalse); + expect(queue.hasDurabilityFailure, isTrue); + expect(failedSummary, isNotNull); + expect(failedSummary!.attentionCount, 1); + expect(failedSummary.queuedCount, 0); + expect(failedSummary.reason, contains('could not be verified')); + expect(repository.calls, isEmpty); + }); + + test( + 'inactive legacy oversized work stays byte-for-byte queued until ' + 'explicit cloud adoption', () async { + final store = MemoryDurableStrategyOutboxStore(); + final oversized = _oversizedOp( + opId: 'legacy-oversized', + elementId: 'large-element', + ); + const key = EntitySyncKey.element('page-one', 'large-element'); + final durable = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'legacy-strategy', + entityKey: key, + pending: PendingOp(op: oversized, clientId: 'legacy-client'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ); + await store.put(durable); + await store.put(_record( + strategyId: 'unrelated-strategy', + opId: 'unrelated-paused', + status: DurableOutboxStatus.paused, + lastError: 'Retry limit reached', + )); + expect(cloudOperationExceedsPolicy(oversized), isTrue); + final originalBytes = jsonEncode(store.values[durable.storageKey]); + final repository = _RecordingRepository(); + var container = _container(store: store, repository: repository); + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + var summary = container + .read(strategyOpQueueProvider) + .accountOutbox + .strategies['legacy-strategy']; + expect(summary, isNotNull); + expect(summary!.queuedCount, 0); + expect(summary.attentionCount, 1); + expect(summary.reason, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + expect(jsonEncode(store.values[durable.storageKey]), originalBytes); + expect( + store + .load() + .records + .singleWhere( + (record) => record.strategyPublicId == 'legacy-strategy', + ) + .status, + DurableOutboxStatus.queued, + ); + + container.dispose(); + container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + + summary = container + .read(strategyOpQueueProvider) + .accountOutbox + .strategies['legacy-strategy']; + expect(summary!.attentionCount, 1); + expect(repository.calls, isEmpty); + expect(jsonEncode(store.values[durable.storageKey]), originalBytes); + expect( + store + .load() + .records + .singleWhere( + (record) => record.strategyPublicId == 'legacy-strategy', + ) + .status, + DurableOutboxStatus.queued, + ); + + notifier.setActiveStrategy('legacy-strategy', accountId: 'account-a'); + final active = container.read(strategyOpQueueProvider); + expect(active.attentionByEntityKey, contains(key)); + expect(active.queuedByEntityKey, isEmpty); + + expect(await notifier.discardRejected({key}), {key}); + expect( + store.load().records.map((record) => record.pending.op.opId), + ['unrelated-paused'], + ); + expect(repository.calls, isEmpty); + }); + + test( + 'reconnection resumes eligible closed-strategy work, following the ' + 'real connection snapshot', () async { + // The snapshot is not overridden: the queue reads the real one, which + // must follow the connection. It once cached its first read, so work + // queued while the socket was opening was never sent. + final connectionChanges = StreamController(); + addTearDown(connectionChanges.close); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'offline-strategy', opId: 'offline')); + final repository = _RecordingRepository(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => _ReadyAuthProvider('account-a')), + convexConnectionProvider.overrideWith((ref) => connectionChanges.stream), + ]); + addTearDown(container.dispose); + container.listen(convexConnectionProvider, (_, __) {}); + connectionChanges.add(false); + await Future.delayed(Duration.zero); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await Future.delayed(const Duration(milliseconds: 50)); + expect(container.read(convexConnectionSnapshotProvider), isFalse); + expect(repository.calls, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.hasWork, + isTrue, + ); + + connectionChanges.add(true); + await _waitUntil(() => repository.calls.length == 1); + expect(container.read(convexConnectionSnapshotProvider), isTrue); + expect(store.values, isEmpty); + }); + + test('a batch that fails to send is reported, redacted, to the debug log', + () async { + appProviderContainer.read(inAppDebugProvider.notifier).clearLogs(); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'failing-strategy', opId: 'failing')); + final repository = _ThrowingRepository(StateError( + 'upload to https://bucket.example.com/a.png' + '?X-Amz-Signature=secret-signature failed', + )); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await _waitUntil( + () => appProviderContainer + .read(inAppDebugProvider) + .any((entry) => entry.source == 'cloud_sync.op_queue'), + ); + + final entry = appProviderContainer + .read(inAppDebugProvider) + .firstWhere((entry) => entry.source == 'cloud_sync.op_queue'); + expect(entry.level, DebugLogLevel.warning); + expect( + entry.message, + 'Cloud sync could not send 1 change(s) for strategy failing-strategy', + ); + expect( + entry.errorText, + 'Bad state: upload to https://bucket.example.com/a.png? ' + 'failed', + ); + expect(store.values, isNotEmpty); + }); + + group('a strategy deleted on the server', () { + test('stops retrying and keeps authored changes for discard', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'deleted', opId: 'edit')); + await store.put(_record(strategyId: 'healthy', opId: 'healthy-edit')); + final repository = _DeletedStrategyRepository({'deleted'}); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await _waitUntil( + () => container + .read(strategyOpQueueProvider) + .accountOutbox + .deletedStrategies + .isNotEmpty, + ); + await Future.delayed(const Duration(milliseconds: 50)); + + final outbox = container.read(strategyOpQueueProvider).accountOutbox; + expect(outbox.deletedStrategies, {'deleted': 1}); + // It holds no attention or work for the strategies that still exist. + expect(outbox.strategies.keys, isNot(contains('deleted'))); + expect(outbox.needsAttention, isFalse); + expect(outbox.hasWork, isFalse); + expect(repository.shellChecks, ['deleted']); + // One send, confirmed deleted: never retried. + expect( + repository.calls.where((call) => call.strategyId == 'deleted'), + hasLength(1), + ); + final kept = store.load().records.single; + expect(kept.status, DurableOutboxStatus.attention); + expect(kept.lastError, cloudStrategyDeletedMessage); + + await container + .read(strategyOpQueueProvider.notifier) + .discardDeletedStrategy('deleted'); + expect(store.values, isEmpty); + expect( + container.read(strategyOpQueueProvider).accountOutbox.deletedStrategies, + isEmpty, + ); + }); + + test('drops changes that only remove things from it', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'deleted', + opId: 'remove', + op: const ElementDeleteOp( + opId: 'remove', + elementPublicId: 'element-one', + pagePublicId: 'page-one', + expectedElementRevision: 1, + ), + )); + final repository = _DeletedStrategyRepository({'deleted'}); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await _waitUntil(() => repository.shellChecks.isNotEmpty); + await _waitUntil(() => store.values.isEmpty); + + final outbox = container.read(strategyOpQueueProvider).accountOutbox; + expect(outbox.deletedStrategies, isEmpty); + expect(outbox.hasWork, isFalse); + }); + + test('NOT_FOUND the server does not confirm is retried as usual', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'present', opId: 'edit')); + // applyBatch says NOT_FOUND, but the strategy's shell still loads. + final repository = _DeletedStrategyRepository( + const {}, + notFoundOnSend: {'present'}, + ); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + await _waitUntil(() => repository.shellChecks.isNotEmpty); + await Future.delayed(const Duration(milliseconds: 50)); + + final record = store.load().records.single; + expect(record.lastError, isNot(cloudStrategyDeletedMessage)); + expect(record.status, isNot(DurableOutboxStatus.attention)); + expect( + container.read(strategyOpQueueProvider).accountOutbox.deletedStrategies, + isEmpty, + ); + }); + }); + + test('a page the server deleted drops all its work, successors too', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'active', + opId: 'paused', + status: DurableOutboxStatus.paused, + ).copyWith( + successorPending: PendingOp( + op: _op(opId: 'successor', elementId: 'element-one', value: 'next'), + clientId: 'client-active', + ), + )); + await store.put(_record( + strategyId: 'active', + opId: 'rejected', + elementId: 'element-two', + status: DurableOutboxStatus.attention, + )); + await store.put(_record( + strategyId: 'active', + opId: 'other-page', + elementId: 'element-three', + status: DurableOutboxStatus.paused, + op: const ElementPatchOp( + opId: 'other-page', + elementPublicId: 'element-three', + pagePublicId: 'page-two', + payload: {'value': 'kept'}, + expectedElementRevision: 1, + ), + )); + final container = _container( + store: store, + repository: _RecordingRepository(), + connected: () => false, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active', accountId: 'account-a'); + await _waitUntil( + () => container.read(strategyOpQueueProvider).durableLoaded, + ); + expect(container.read(strategyOpQueueProvider).lastError, isNotNull); + + expect(await notifier.discardDeletedPage('page-one'), isTrue); + + expect( + store.load().records.map((record) => record.entityKey.pageId), + ['page-two'], + ); + final queue = container.read(strategyOpQueueProvider); + expect(queue.pausedByEntityKey.keys.map((key) => key.pageId), ['page-two']); + expect(queue.attentionByEntityKey, isEmpty); + expect(queue.successorByEntityKey, isEmpty); + // The attention was the dropped work's; the paused edit on page two + // speaks for itself. + expect(queue.lastError, isNot(contains('rejected'))); + }); + + test('a deleted page drops work whose first save could not be verified', + () async { + final container = _container( + store: _FailingPutStore(), + repository: _RecordingRepository(), + connected: () => false, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active', accountId: 'account-a'); + await notifier.enqueue( + _op(opId: 'unverified', elementId: 'element-one'), + flushImmediately: false, + ); + expect(container.read(strategyOpQueueProvider).attentionByEntityKey, + isNotEmpty); + + expect(await notifier.discardDeletedPage('page-one'), isTrue); + + final queue = container.read(strategyOpQueueProvider); + expect(queue.attentionByEntityKey, isEmpty); + expect(queue.lastError, isNull); + }); + + test('a deleted page drops a send the app closed in the middle of', () async { + // Saved as in flight, then the app closed before the server answered. + // After a restart, offline, it only waits to be replayed. + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record( + strategyId: 'active', + opId: 'interrupted', + status: DurableOutboxStatus.inFlight, + )); + final container = _container( + store: store, + repository: _RecordingRepository(), + connected: () => false, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active', accountId: 'account-a'); + + expect(await notifier.discardDeletedPage('page-one'), isTrue); + + expect(store.values, isEmpty); + expect(container.read(strategyOpQueueProvider).queuedByEntityKey, isEmpty); + }); + + test('a deleted page keeps a send the server has not answered', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'active', opId: 'sending')); + final repository = _HeldFirstRepository(); + final container = _container(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active', accountId: 'account-a'); + await repository.firstStarted.future; + + expect(await notifier.discardDeletedPage('page-one'), isFalse); + expect(store.values, hasLength(1)); + + repository.releaseFirst(); + await _waitUntil(() => store.values.isEmpty); + expect(await notifier.discardDeletedPage('page-one'), isTrue); + }); + + test( + "a restored page sends again, under new op ids, only its changes " + 'refused while it was deleted', () async { + final store = MemoryDurableStrategyOutboxStore(); + // A new element, so no revision to rebase on: before, it could not be + // retried at all. + await store.put(_record( + strategyId: 'active', + opId: 'refused-add', + status: DurableOutboxStatus.attention, + lastError: pageDeletedMessage, + op: const ElementAddOp( + opId: 'refused-add', + elementPublicId: 'element-one', + pagePublicId: 'page-one', + payload: {'value': 'new'}, + sortIndex: 3, + ), + )); + await store.put(_record( + strategyId: 'active', + opId: 'conflict', + elementId: 'element-two', + status: DurableOutboxStatus.attention, + lastError: 'revision_mismatch', + )); + await store.put(_record( + strategyId: 'active', + opId: 'other-page', + elementId: 'element-three', + status: DurableOutboxStatus.attention, + lastError: pageDeletedMessage, + op: const ElementPatchOp( + opId: 'other-page', + elementPublicId: 'element-three', + pagePublicId: 'page-two', + payload: {'value': 'kept'}, + expectedElementRevision: 1, + ), + )); + final container = _container( + store: store, + repository: _RecordingRepository(), + connected: () => false, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('active', accountId: 'account-a'); + + await notifier.retryRestoredPage('page-one'); + + final queue = container.read(strategyOpQueueProvider); + final retried = queue + .queuedByEntityKey[ + const EntitySyncKey.element('page-one', 'element-one')]! + .pending + .op as ElementAddOp; + // The server answers a known op id as it did before. + expect(retried.opId, isNot('refused-add')); + expect(retried.payload, {'value': 'new'}); + expect(retried.sortIndex, 3); + // A conflict on the page, and work for another page, still wait. + expect(queue.attentionByEntityKey.keys, { + const EntitySyncKey.element('page-one', 'element-two'), + const EntitySyncKey.element('page-two', 'element-three'), + }); + final record = store + .load() + .records + .singleWhere((record) => record.entityKey.entityId == 'element-one'); + expect(record.status, DurableOutboxStatus.queued); + expect(record.pending.op.opId, retried.opId); + }); + + test('auth readiness recovery resumes eligible closed-strategy work', + () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_record(strategyId: 'auth-waiting', opId: 'auth-op')); + final repository = _RecordingRepository(); + final auth = _MutableAuthProvider(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => auth), + convexConnectionSnapshotProvider.overrideWithValue(true), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ]); + addTearDown(container.dispose); + + container.read(strategyOpQueueProvider); + await Future.delayed(const Duration(milliseconds: 50)); + expect(repository.calls, isEmpty); + + auth.markReady(); + await _waitUntil(() => repository.calls.length == 1); + expect(repository.calls.single.strategyId, 'auth-waiting'); + }); +} + +ProviderContainer _container({ + required DurableStrategyOutboxStore store, + required ConvexStrategyRepository repository, + bool Function()? connected, + Stream? connectionChanges, + String authAccountId = 'account-a', + AuthProvider Function()? createAuth, +}) { + return ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith( + createAuth ?? () => _ReadyAuthProvider(authAccountId), + ), + convexConnectionSnapshotProvider.overrideWith( + (ref) => connected?.call() ?? true, + ), + convexConnectionProvider.overrideWith( + (ref) => connectionChanges ?? Stream.value(true), + ), + ]); +} + +DurableOutboxRecord _record({ + String accountId = 'account-a', + required String strategyId, + required String opId, + String elementId = 'element-one', + DurableOutboxStatus status = DurableOutboxStatus.queued, + String? lastError, + StrategyOp? op, +}) { + final now = DateTime(2026); + op ??= _op(opId: opId, elementId: elementId); + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: EntitySyncKey.element(op.pagePublicId ?? 'page-one', elementId), + pending: PendingOp(op: op, clientId: 'client-$strategyId'), + status: status, + createdAt: now, + updatedAt: now, + lastError: lastError, + ); +} + +ElementPatchOp _op({ + required String opId, + required String elementId, + String value = 'safe', +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-one', + payload: {'value': value}, + expectedElementRevision: 1, + ); +} + +ElementPatchOp _oversizedOp({ + required String opId, + required String elementId, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-one', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': List.filled(310000, '界').join()}, + }, + expectedElementRevision: 1, + ); +} + +Future _waitUntil(bool Function() condition) async { + for (var i = 0; i < 100; i += 1) { + if (condition()) return; + await Future.delayed(const Duration(milliseconds: 10)); + } + fail('Condition was not reached before timeout.'); +} + +class _ReadyAuthProvider extends AuthProvider { + _ReadyAuthProvider(this.accountId); + + final String accountId; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +/// Signed in and ready as one account at a time, switched by [signInAs]: +/// a sign-out of one account and sign-in of another on the same device. +class _SwitchableAuthProvider extends AuthProvider { + _SwitchableAuthProvider(this._accountId); + + String _accountId; + + @override + AppAuthState build() => _readyAs(_accountId); + + void signInAs(String accountId) { + _accountId = accountId; + state = _readyAs(accountId); + } + + static AppAuthState _readyAs(String accountId) => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: _user(accountId), + ); +} + +class _MutableAuthProvider extends AuthProvider { + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + user: _user('account-a'), + ); + + void markReady() { + state = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: _user('account-a'), + ); + } +} + +User _user(String accountId) => User( + id: accountId, + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ); + +typedef _Call = ({String strategyId, List ops}); + +class _FailingPutStore extends MemoryDurableStrategyOutboxStore { + @override + Future put(DurableOutboxRecord record) async { + throw StateError('disk write failed'); + } +} + +class _RecordingRepository extends ConvexStrategyRepository { + _RecordingRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List<_Call> calls = []; + final List accountSubjects = []; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + accountSubjects.add(accountSubject); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _ThrowingRepository extends _RecordingRepository { + _ThrowingRepository(this.error); + + final Object error; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + throw error; + } +} + +/// applyBatch fails with NOT_FOUND for [deleted] (and [notFoundOnSend]); +/// the shell check confirms only [deleted]. +class _DeletedStrategyRepository extends _RecordingRepository { + _DeletedStrategyRepository( + this.deleted, { + Set? notFoundOnSend, + }) : notFoundOnSend = notFoundOnSend ?? deleted; + + final Set deleted; + final Set notFoundOnSend; + final List shellChecks = []; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + if (!notFoundOnSend.contains(strategyPublicId)) { + return super.applyBatch( + strategyPublicId: strategyPublicId, + clientId: clientId, + ops: ops, + accountSubject: accountSubject, + ); + } + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + throw ConvexFunctionException( + code: ConvexErrorCode.notFound, + rawCode: 'NOT_FOUND', + message: 'Strategy not found: $strategyPublicId', + ); + } + + @override + Future strategyIsDeleted(String strategyPublicId) async { + shellChecks.add(strategyPublicId); + return deleted.contains(strategyPublicId); + } +} + +class _HeldFirstRepository extends _RecordingRepository { + final firstStarted = Completer(); + final _release = Completer(); + + void releaseFirst() => _release.complete(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) { + firstStarted.complete(); + await _release.future; + } + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +/// Holds its first request until [refuseFirst], then refuses it as the +/// server refuses a batch bound to an account other than the caller's. +class _HeldRefusalRepository extends _RecordingRepository { + final firstStarted = Completer(); + final _refuse = Completer(); + + void refuseFirst() => _refuse.complete(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + accountSubjects.add(accountSubject); + firstStarted.complete(); + await _refuse.future; + throw const ConvexFunctionException( + code: ConvexErrorCode.forbidden, + rawCode: 'FORBIDDEN', + message: 'Forbidden: these changes belong to a different account', + ); + } +} + +class _FailFirstRepository extends _RecordingRepository { + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add((strategyId: strategyPublicId, ops: List.of(ops))); + if (calls.length == 1) throw StateError('temporary failure'); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +/// Holds the first write that marks a record in flight (a batch claim) +/// until [allowClaim], and lists the status of every write. +class _HeldClaimStore extends MemoryDurableStrategyOutboxStore { + final claimStarted = Completer(); + final _allowClaim = Completer(); + final List putStatuses = []; + + void allowClaim() => _allowClaim.complete(); + + @override + Future put(DurableOutboxRecord record) async { + if (record.status == DurableOutboxStatus.inFlight && + !claimStarted.isCompleted) { + claimStarted.complete(); + await _allowClaim.future; + } + putStatuses.add(record.status); + await super.put(record); + } +} + +class _BlockingSuccessorStore extends MemoryDurableStrategyOutboxStore { + final successorWriteStarted = Completer(); + final allowSuccessorWrite = Completer(); + var _blocked = false; + + @override + Future put(DurableOutboxRecord record) async { + if (!_blocked && record.successorPending != null) { + _blocked = true; + successorWriteStarted.complete(); + await allowSuccessorWrite.future; + } + await super.put(record); + } +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/hive_desktop_rollback_test.dart b/test/hive_desktop_rollback_test.dart new file mode 100644 index 00000000..b019d5f2 --- /dev/null +++ b/test/hive_desktop_rollback_test.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/src/binary/binary_reader_impl.dart'; +import 'package:hive_ce/src/binary/binary_writer_impl.dart'; +import 'package:hive_ce/src/hive_impl.dart'; +import 'package:hive_ce/src/util/logger.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/folder_icons.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/hive/hive_adapters.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +import 'desktop_4_6_3_adapters_test_support.dart'; + +/// This build's adapters. +HiveImpl _currentRegistry() { + final registry = HiveImpl(); + registerIcarusAdapters(registry); + return registry; +} + +/// What desktop 4.6.3 registers: this build's adapters, which are unchanged +/// since that release, except the eight this build writes by hand. +HiveImpl _desktop463Registry() { + final registry = _currentRegistry(); + // hive_ce warns on every override, which is the point here. + final previousLevel = Logger.level; + Logger.level = LoggerLevel.error; + try { + return registry + ..registerAdapter(Desktop463PlacedAgentAdapter(), override: true) + ..registerAdapter(Desktop463PlacedAbilityAdapter(), override: true) + ..registerAdapter(Desktop463StrategyPageAdapter(), override: true) + ..registerAdapter(Desktop463FolderAdapter(), override: true) + ..registerAdapter(Desktop463FreeDrawingAdapter(), override: true) + ..registerAdapter(Desktop463LineAdapter(), override: true) + ..registerAdapter(Desktop463RectangleDrawingAdapter(), override: true) + ..registerAdapter(Desktop463EllipseDrawingAdapter(), override: true); + } finally { + Logger.level = previousLevel; + } +} + +Uint8List _write(HiveImpl registry, Object value) { + final writer = BinaryWriterImpl(registry)..write(value); + return Uint8List.fromList(writer.toBytes()); +} + +T _read(HiveImpl registry, Uint8List bytes) => + BinaryReaderImpl(bytes, registry).read() as T; + +/// Saved by one build, opened by the other. +T _across(HiveImpl from, HiveImpl to, T value) => + _read(to, _write(from, value)); + +PlacedAbility _ability( + String id, { + AbilityVisualState visualState = const AbilityVisualState(), +}) { + return PlacedAbility( + id: id, + data: AgentData.agents[AgentType.breach]!.abilities.first, + position: const Offset(30, 40), + visualState: visualState, + ); +} + +StrategyPage _richPage() { + const hidden = AbilityVisualState(showRangeFill: false); + return StrategyPage( + id: 'page-1', + name: 'Page 1', + drawingData: [ + Line( + id: 'line-1', + lineStart: const Offset(1, 2), + lineEnd: const Offset(3, 4), + colorValue: 0xFFFFFFFF, + isDotted: false, + hasArrow: false, + ), + EllipseDrawing( + id: 'ellipse-1', + start: const Offset(5, 6), + end: const Offset(7, 8), + colorValue: 0xFF22C55E, + isDotted: true, + hasArrow: false, + ), + ], + agentData: [ + PlacedAgent( + id: 'agent-plain', + type: AgentType.breach, + position: const Offset(10, 20), + weapon: WeaponType.vandal, + ), + PlacedViewConeAgent( + id: 'agent-cone', + type: AgentType.killjoy, + position: const Offset(30, 40), + presetType: UtilityType.viewCone90, + rotation: 1.2, + length: 50, + visionElevation: 1, + weapon: WeaponType.operator, + ), + PlacedCircleAgent( + id: 'agent-circle', + type: AgentType.viper, + position: const Offset(50, 60), + diameterMeters: 12, + colorValue: 0xFF3B82F6, + opacityPercent: 45, + ), + ], + abilityData: [_ability('ability-map', visualState: hidden)], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + lineUpGroups: [ + LineUpGroup( + id: 'group-1', + agent: PlacedAgent( + id: 'lineup-agent', + type: AgentType.breach, + position: const Offset(70, 80), + weapon: WeaponType.sheriff, + ), + items: [ + LineUpItem( + id: 'item-1', + ability: _ability('lineup-ability-1', visualState: hidden), + ), + LineUpItem( + id: 'item-2', + ability: _ability('lineup-ability-2'), + ), + ], + ), + ], + ); +} + +Object? _plain(Object? json) => jsonDecode(jsonEncode(json)); + +void _expectSamePage(StrategyPage actual, StrategyPage expected) { + expect( + _plain(actual.toJson('strategy')), + _plain(expected.toJson('strategy')), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final current = _currentRegistry(); + final desktop463 = _desktop463Registry(); + + test('a page is saved byte for byte as desktop 4.6.3 saves it', () { + final page = _richPage(); + expect(_write(current, page), _write(desktop463, page)); + }); + + test('desktop 4.6.3 opens a page this build saved, whole', () { + final page = _richPage(); + final opened = _across(current, desktop463, page); + + _expectSamePage(opened, page); + expect(opened.drawingData.last, isA()); + expect(opened.agentData[1], isA()); + expect(opened.agentData[2], isA()); + expect( + opened.agentData.map((agent) => agent.weapon), + [WeaponType.vandal, WeaponType.operator, WeaponType.none], + ); + expect(opened.abilityData.single.visualState.showRangeFill, isFalse); + expect(opened.lineUpOrigins.single.agent.weapon, WeaponType.sheriff); + }); + + test('what desktop 4.6.3 saves back, this build opens whole', () { + final page = _richPage(); + final rolledBack = _across(current, desktop463, page); + _expectSamePage(_across(desktop463, current, rolledBack), page); + }); + + test('every agent and ability survives a rollback', () { + final page = StrategyPage( + id: 'page-roster', + name: 'Roster', + drawingData: const [], + agentData: [ + for (final type in AgentType.values) + PlacedAgent( + id: 'agent-${type.name}', + type: type, + position: const Offset(1, 1), + ), + ], + abilityData: [ + for (final agent in AgentData.agents.values) + for (var i = 0; i < agent.abilities.length; i++) + PlacedAbility( + id: 'ability-${agent.type.name}-$i', + data: agent.abilities[i], + position: const Offset(5, 5), + ), + ], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ); + + _expectSamePage(_across(current, desktop463, page), page); + }); + + test('folders keep their exact icon and color through a rollback', () { + for (final iconId in [ + FolderIconRegistry.defaultId, + FolderIconRegistry.sentinelRoleId, + FolderIconRegistry.premierModeId, + ]) { + final folder = Folder( + id: 'folder-$iconId', + name: 'Execs', + dateCreated: DateTime.utc(2026, 8, 30), + iconId: iconId, + color: FolderColor.custom, + customColor: const Color(0xFF22C55E), + ); + expect(_write(current, folder), _write(desktop463, folder)); + final opened = _across(current, desktop463, folder); + expect(opened.iconId, iconId); + expect(opened.customColor, const Color(0xFF22C55E)); + } + }); + + group('records earlier cloud builds wrote', () { + BinaryReaderImpl fieldBag(Map fields) { + final writer = BinaryWriterImpl(current)..writeByte(fields.length); + for (final MapEntry(:key, :value) in fields.entries) { + writer + ..writeByte(key) + ..write(value); + } + return BinaryReaderImpl(Uint8List.fromList(writer.toBytes()), current); + } + + test('ability visual state as primitives in fields 10-14', () { + final ability = _ability('ability-old-cloud'); + final restored = PlacedAbilityAdapter().read( + fieldBag({ + 0: ability.data, + 1: ability.isAlly, + 2: ability.rotation, + 3: ability.id, + 4: false, + 5: ability.position, + 6: ability.length, + 7: ability.lineUpID, + 8: ability.armLengthsMeters, + 10: false, + 11: false, + 12: true, + 13: false, + 14: false, + }), + ); + + expect(restored.visualState.showRangeOutline, isFalse); + expect(restored.visualState.showRangeFill, isFalse); + expect(restored.visualState.showInnerOutline, isTrue); + expect(restored.visualState.showInnerFill, isFalse); + expect(restored.visualState.showVisionCone, isFalse); + }); + + test('folders with the exact icon at 7 and the color at 8', () { + final restored = FolderAdapter().read( + fieldBag({ + 0: 'Execs', + 1: 'folder-old-cloud', + 2: null, + 3: DateTime.utc(2026, 8, 30), + 4: Icons.folder, + 5: FolderColor.custom, + 6: const Color(0xFF22C55E), + 7: FolderIconRegistry.sentinelRoleId, + 8: 0xFF22C55E, + }), + ); + + expect(restored.iconId, FolderIconRegistry.sentinelRoleId); + expect(restored.customColor, const Color(0xFF22C55E)); + }); + }); +} diff --git a/test/hive_registry_layout_test.dart b/test/hive_registry_layout_test.dart new file mode 100644 index 00000000..628792c8 --- /dev/null +++ b/test/hive_registry_layout_test.dart @@ -0,0 +1,425 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:hive_ce/src/binary/binary_reader_impl.dart'; +import 'package:hive_ce/src/binary/binary_writer_impl.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/bounding_box.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/hive/hive_adapters.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; + +// Desktop 4.6.2+102 shipped these numbers to users. They must never move: +// a renumbered adapter silently misreads every library written before it. + +void _ensureAdaptersRegistered() { + if (!Hive.isAdapterRegistered(strategyPageAdapterTypeId)) { + registerIcarusAdapters(Hive); + } +} + +Uint8List _write(TypeAdapter adapter, T value) { + _ensureAdaptersRegistered(); + final writer = BinaryWriterImpl(Hive); + adapter.write(writer, value); + return Uint8List.fromList(writer.toBytes()); +} + +Map _fields(Uint8List bytes) { + final reader = BinaryReaderImpl(bytes, Hive); + final count = reader.readByte(); + return { + for (var i = 0; i < count; i++) reader.readByte(): reader.read(), + }; +} + +/// Encodes a field map exactly as a generated Hive adapter lays it out. +Uint8List _encodeFields(Map fields) { + _ensureAdaptersRegistered(); + final writer = BinaryWriterImpl(Hive)..writeByte(fields.length); + for (final entry in fields.entries) { + writer + ..writeByte(entry.key) + ..write(entry.value); + } + return Uint8List.fromList(writer.toBytes()); +} + +StrategyPage _read(Uint8List bytes) => + StrategyPageAdapter().read(BinaryReaderImpl(bytes, Hive)); + +PlacedAbility _ability(String id, Offset position) => PlacedAbility( + id: id, + data: AgentData.agents[AgentType.sova]!.abilities.first, + position: position, + ); + +/// Two origins share one landing (fan-in), and the second origin also fans +/// out to a second landing. +LineUpGraph _fanInGraph() { + return LineUpGraph( + origins: [ + LineUpOrigin( + id: 'origin-a', + agent: PlacedAgent( + id: 'origin-a', + type: AgentType.sova, + position: const Offset(100, 200), + lineUpID: 'origin-a', + weapon: WeaponType.vandal, + ), + ), + LineUpOrigin( + id: 'origin-b', + agent: PlacedAgent( + id: 'origin-b', + type: AgentType.sova, + position: const Offset(300, 400), + lineUpID: 'origin-b', + weapon: WeaponType.operator, + ), + ), + ], + landings: [ + LineUpLanding( + id: 'landing-shared', + ability: _ability('landing-shared', const Offset(500, 500)), + ), + LineUpLanding( + id: 'landing-solo', + ability: _ability('landing-solo', const Offset(600, 650)), + ), + ], + links: [ + LineUpLink( + id: 'link-a', + originId: 'origin-a', + landingId: 'landing-shared', + name: 'A main dart', + youtubeLink: 'https://youtu.be/a', + notes: 'jump throw', + images: [SimpleImageData(id: 'image-a', fileExtension: '.png')], + ), + LineUpLink( + id: 'link-b', + originId: 'origin-b', + landingId: 'landing-shared', + name: 'B long dart', + ), + LineUpLink( + id: 'link-c', + originId: 'origin-b', + landingId: 'landing-solo', + ), + ], + ); +} + +List _drawings() => [ + Line( + id: 'line-1', + lineStart: const Offset(1, 2), + lineEnd: const Offset(3, 4), + colorValue: 0xFFFFFFFF, + isDotted: false, + hasArrow: true, + boundingBox: + BoundingBox(min: const Offset(1, 2), max: const Offset(3, 4)), + ), + EllipseDrawing( + id: 'ellipse-1', + start: const Offset(5, 6), + end: const Offset(7, 8), + colorValue: 0xFF22C55E, + isDotted: true, + hasArrow: false, + boundingBox: + BoundingBox(min: const Offset(5, 6), max: const Offset(7, 8)), + ), + ]; + +List _agents() => [ + PlacedAgent( + id: 'agent-plain', + type: AgentType.jett, + position: const Offset(10, 20), + weapon: WeaponType.sheriff, + ), + PlacedViewConeAgent( + id: 'agent-cone', + type: AgentType.killjoy, + position: const Offset(30, 40), + presetType: UtilityType.viewCone90, + rotation: 1.2, + length: 50, + visionElevation: 1, + weapon: WeaponType.phantom, + ), + PlacedCircleAgent( + id: 'agent-circle', + type: AgentType.viper, + position: const Offset(50, 60), + diameterMeters: 12, + colorValue: 0xFF3B82F6, + opacityPercent: 45, + weapon: WeaponType.judge, + ), + ]; + +StrategyPage _page({LineUpGraph? graph}) { + final lineUps = graph ?? _fanInGraph(); + return StrategyPage( + id: 'page-1', + name: 'Page 1', + isAutoNamed: false, + drawingData: _drawings(), + agentData: _agents(), + abilityData: [_ability('ability-map', const Offset(70, 80))], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 2, + isAttack: false, + settings: StrategySettings(), + lineUpOrigins: lineUps.origins, + lineUpLandings: lineUps.landings, + lineUpLinks: lineUps.links, + ); +} + +String _json(Object? value) => jsonEncode(value); + +void _expectSameGraph(LineUpGraph actual, LineUpGraph expected) { + expect(_json(actual.toJson()), _json(expected.toJson())); +} + +void _expectSameContent(StrategyPage actual, StrategyPage expected) { + expect(actual.id, expected.id); + expect(actual.name, expected.name); + expect(actual.isAutoNamed, expected.isAutoNamed); + expect(actual.sortIndex, expected.sortIndex); + expect(actual.isAttack, expected.isAttack); + expect( + DrawingProvider.objectToJson(actual.drawingData), + DrawingProvider.objectToJson(expected.drawingData), + ); + expect( + AgentProvider.objectToJson(actual.agentData), + AgentProvider.objectToJson(expected.agentData), + ); + expect( + actual.abilityData.map((ability) => ability.id), + expected.abilityData.map((ability) => ability.id), + ); + _expectSameGraph(actual.lineUpGraph, expected.lineUpGraph); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('shipped Hive typeIds are fixed', () { + expect(PlacedAgentAdapter().typeId, 2); + expect(PlacedAbilityAdapter().typeId, 3); + expect(AbilityInfoAdapter().typeId, 9); + expect(StrategyPageAdapter().typeId, 20); + expect(LineUpAdapter().typeId, 21); + expect(SimpleImageDataAdapter().typeId, 22); + expect(PlacedViewConeAgentAdapter().typeId, 29); + expect(PlacedCircleAgentAdapter().typeId, 30); + expect(AbilityVisualStateAdapter().typeId, 32); + expect(LineUpGroupAdapter().typeId, 33); + expect(LineUpItemAdapter().typeId, 34); + expect(LineUpOriginAdapter().typeId, 35); + expect(LineUpLandingAdapter().typeId, 36); + expect(LineUpLinkAdapter().typeId, 37); + expect(WeaponTypeAdapter().typeId, 38); + }); + + test('cloud-only adapters sit above the shipped range', () { + expect(CloudMediaJobStateAdapter().typeId, 39); + }); + + test('agent weapons use the shipped field slots', () { + final agents = _agents(); + expect( + _fields(_write(PlacedAgentAdapter(), agents[0] as PlacedAgent))[7], + WeaponType.sheriff, + ); + expect( + _fields( + _write(PlacedViewConeAgentAdapter(), agents[1] as PlacedViewConeAgent), + )[10], + WeaponType.phantom, + ); + expect( + _fields( + _write(PlacedCircleAgentAdapter(), agents[2] as PlacedCircleAgent), + )[9], + WeaponType.judge, + ); + }); + + test('lineup graph types use the shipped field slots', () { + final graph = _fanInGraph(); + final origin = _fields(_write(LineUpOriginAdapter(), graph.origins.first)); + expect(origin[0], 'origin-a'); + expect(origin[1], isA()); + final landing = + _fields(_write(LineUpLandingAdapter(), graph.landings.first)); + expect(landing[0], 'landing-shared'); + expect(landing[1], isA()); + final link = _fields(_write(LineUpLinkAdapter(), graph.links.first)); + expect(link[0], 'link-a'); + expect(link[1], 'origin-a'); + expect(link[2], 'landing-shared'); + expect(link[3], 'A main dart'); + expect(link[4], 'https://youtu.be/a'); + expect(link[5], 'jump throw'); + expect(link[6], isA()); + }); + + group('StrategyPage adapter', () { + test('writes the desktop 4.6.3 layout and round-trips losslessly', () { + final page = _page(); + final bytes = _write(StrategyPageAdapter(), page); + final fields = _fields(bytes); + + // Exactly the slots desktop 4.6.3 writes, so a rollback keeps the page. + expect(fields.keys.toSet(), { + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 17, // + }); + List ids(Object? slot, String Function(dynamic) id) => + [for (final item in slot as List) id(item)]; + expect(ids(fields[15], (o) => o.id), page.lineUpOrigins.map((o) => o.id)); + expect( + ids(fields[16], (l) => l.id), page.lineUpLandings.map((l) => l.id)); + expect(ids(fields[17], (l) => l.id), page.lineUpLinks.map((l) => l.id)); + + final restored = _read(bytes); + _expectSameContent(restored, page); + final shared = restored.lineUpGraph.links + .where((link) => link.landingId == 'landing-shared') + .map((link) => link.originId); + expect(shared, ['origin-a', 'origin-b']); + expect(restored.agentData[1], isA()); + expect(restored.drawingData.last, isA()); + }); + + test('weapons stay in the agent and lineup slots', () { + final page = _page(); + final fields = _fields(_write(StrategyPageAdapter(), page)); + + expect( + (fields[4] as List).cast().map((a) => a.weapon), + [WeaponType.sheriff, WeaponType.phantom, WeaponType.judge], + ); + expect( + (fields[15] as List).cast().map((o) => o.agent.weapon), + [WeaponType.vandal, WeaponType.operator], + ); + }); + + test('reads pages written by desktop 4.6 (graph at 15-17, no mirrors)', () { + final page = _page(); + // Field layout of the generated StrategyPageAdapter that shipped in + // desktop-stable-v4.6.2+102. + final bytes = _encodeFields({ + 0: page.id, + 1: page.sortIndex, + 2: page.name, + 3: page.drawingData, + 4: page.agentData, + 5: page.abilityData, + 6: page.textData, + 7: page.imageData, + 8: page.utilityData, + 9: page.isAttack, + 10: page.settings, + // ignore: deprecated_member_use_from_same_package + 11: page.lineUps, + // ignore: deprecated_member_use_from_same_package + 12: page.lineUpGroups, + 14: page.isAutoNamed, + 15: page.lineUpOrigins, + 16: page.lineUpLandings, + 17: page.lineUpLinks, + }); + + final restored = _read(bytes); + _expectSameContent(restored, page); + + // Saving it again through the merged adapter loses nothing either. + _expectSameContent(_read(_write(StrategyPageAdapter(), restored)), page); + }); + + test('reads pages cloud builds wrote with JSON mirrors (13, 18-20)', () { + final page = _page(); + // The 3.2.3-safe layout cloud builds wrote between the main merge and + // the move to desktop 4.6.3's layout: a subset in the legacy slots, the + // whole page in the mirrors. + final bytes = _encodeFields({ + 0: page.id, + 1: page.sortIndex, + 2: page.name, + 3: const [], + 4: const [], + 5: const [], + 6: page.textData, + 7: page.imageData, + 8: page.utilityData, + 9: page.isAttack, + 10: page.settings, + 11: const [], + 13: AgentProvider.objectToJson(page.agentData), + 14: page.isAutoNamed, + 18: DrawingProvider.objectToJson(page.drawingData), + 19: LineUpProvider.objectToJson(page.lineUpGraph), + 20: AbilityProvider.objectToJson(page.abilityData), + }); + + _expectSameContent(_read(bytes), page); + }); + + test('reads pages written by cloud builds before the main merge', () { + final page = _page( + graph: LineUpGraph.fromLegacyGroups(_fanInGraph().toLegacyGroups()), + ); + final bytes = _encodeFields({ + 0: page.id, + 1: page.sortIndex, + 2: page.name, + 3: const [], + 4: const [], + 5: page.abilityData, + 6: page.textData, + 7: page.imageData, + 8: page.utilityData, + 9: page.isAttack, + 10: page.settings, + 11: const [], + 13: AgentProvider.objectToJson(page.agentData), + 14: page.isAutoNamed, + 15: DrawingProvider.objectToJson(page.drawingData), + 16: jsonEncode( + page.lineUpGraph + .toLegacyGroups() + .map((group) => group.toJson()) + .toList(), + ), + }); + + _expectSameContent(_read(bytes), page); + }); + }); +} diff --git a/test/ica_drop_target_test.dart b/test/ica_drop_target_test.dart index 47a142b9..ccc15a2b 100644 --- a/test/ica_drop_target_test.dart +++ b/test/ica_drop_target_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; import 'package:icarus/widgets/ica_drop_target.dart'; void main() { diff --git a/test/image_provider_cloud_durability_test.dart b/test/image_provider_cloud_durability_test.dart new file mode 100644 index 00000000..1f8fb997 --- /dev/null +++ b/test/image_provider_cloud_durability_test.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _RecordingImageProvider extends PlacedImageProvider { + final List locallySavedImageIds = []; + + @override + Future saveSecureImage( + Uint8List imageBytes, + String imageID, + String fileExtenstion, { + required String? strategyId, + }) async { + locallySavedImageIds.add(imageID); + } +} + +class _FailingMediaQueue extends CloudMediaUploadQueueNotifier { + bool enqueueAttempted = false; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future enqueuePlacedImageUpload({ + required String imagePublicId, + String? strategyPublicId, + String? fileExtension, + String? mimeType, + int? width, + int? height, + }) async { + enqueueAttempted = true; + throw StateError('outbox write failed'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + + test('failed media outbox write keeps the local source and fails closed', + () async { + final imageProvider = _RecordingImageProvider(); + final mediaQueue = _FailingMediaQueue(); + final container = ProviderContainer( + overrides: [ + placedImageProvider.overrideWith(() => imageProvider), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + ], + ); + addTearDown(container.dispose); + + await expectLater( + container.read(placedImageProvider.notifier).addImage( + imageBytes: Uint8List.fromList([1, 2, 3]), + strategyId: 'strategy-a', + strategySource: StrategySource.cloud, + fileExtension: '.png', + aspectRatio: 1, + ), + throwsA(isA()), + ); + + expect(mediaQueue.enqueueAttempted, isTrue); + expect(imageProvider.locallySavedImageIds, hasLength(1)); + expect(container.read(placedImageProvider).images, isEmpty); + }); +} diff --git a/test/interactive_map_canonical_center_test.dart b/test/interactive_map_canonical_center_test.dart index dff90fc7..0fceb5e6 100644 --- a/test/interactive_map_canonical_center_test.dart +++ b/test/interactive_map_canonical_center_test.dart @@ -5,6 +5,7 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/traversal_speed.dart'; import 'package:icarus/interactive_map.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; import 'package:icarus/providers/drawing_provider.dart'; import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; @@ -94,4 +95,58 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); + + testWidgets('viewer canvas keeps navigation but disables edit layers', + (tester) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1500, 900); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final container = ProviderContainer( + overrides: [ + mapProvider.overrideWith(_FixedMapProvider.new), + drawingProvider.overrideWith(_EmptyDrawingProvider.new), + penProvider.overrideWith(_FixedPenProvider.new), + effectiveMapThemePaletteProvider.overrideWith( + (ref) => MapThemeProfilesProvider.immutableDefaultPalette, + ), + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole('viewer'), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: InteractiveMap()), + ), + ), + ); + await tester.pump(); + + expect(find.byType(InteractiveViewer), findsOneWidget); + expect( + tester + .widget( + find.byKey(const ValueKey('strategy-canvas-object-editor')), + ) + .ignoring, + isTrue, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('strategy-canvas-drawing-editor')), + ) + .ignoring, + isTrue, + ); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + }); } diff --git a/test/library_entries_test.dart b/test/library_entries_test.dart new file mode 100644 index 00000000..2dd4c65c --- /dev/null +++ b/test/library_entries_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_filter_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/folder_card.dart'; +import 'package:icarus/widgets/library_entries.dart'; + +void main() { + test('a strategy present in both stores shows once, as its cloud copy', () { + final local = LibraryStrategyRow.local( + _localStrategy('shared-id', 'Local copy'), + showDeviceBadge: true, + ); + final onlyLocal = LibraryStrategyRow.local( + _localStrategy('local-only', 'Mine'), + showDeviceBadge: true, + ); + final cloud = + LibraryStrategyRow.cloud(_cloudStrategy('shared-id', 'Cloud')); + + final merged = mergeLibraryStrategies( + local: [local, onlyLocal], + cloud: [cloud], + ); + + expect(merged.map((row) => row.id), ['shared-id', 'local-only']); + expect(merged.first.cloud, isNotNull); + expect(merged.last.showDeviceBadge, isTrue); + }); + + test('folders keep their store after merging', () { + final merged = mergeLibraryFolders( + local: [_folderRow('a', LibraryWorkspace.local)], + cloud: [_folderRow('b', LibraryWorkspace.cloud)], + ); + + expect( + merged.map((row) => row.store), + [LibraryWorkspace.cloud, LibraryWorkspace.local], + ); + }); + + test('sorting mixes both stores on the chosen field', () { + final rows = [ + LibraryStrategyRow.local( + _localStrategy('b', 'Bravo', created: DateTime(2024, 1, 2)), + showDeviceBadge: false, + ), + LibraryStrategyRow.cloud( + _cloudStrategy('a', 'Alpha', created: DateTime(2024, 1, 3)), + ), + LibraryStrategyRow.local( + _localStrategy('c', 'Charlie', created: DateTime(2024, 1, 1)), + showDeviceBadge: false, + ), + ]; + + final byName = sortLibraryStrategies( + rows, + StrategyFilterState( + sortBy: SortBy.alphabetical, + sortOrder: SortOrder.ascending, + ), + ); + expect(byName.map((row) => row.id), ['a', 'b', 'c']); + + final newestFirst = sortLibraryStrategies( + rows, + StrategyFilterState( + sortBy: SortBy.dateCreated, + sortOrder: SortOrder.descending, + ), + ); + expect(newestFirst.map((row) => row.id), ['a', 'b', 'c']); + }); +} + +StrategyData _localStrategy(String id, String name, {DateTime? created}) { + final at = created ?? DateTime(2024, 1, 1); + return StrategyData( + id: id, + name: name, + mapData: MapValue.ascent, + versionNumber: 1, + folderID: null, + pages: const [], + createdAt: at, + lastEdited: at, + ); +} + +CloudStrategyEntry _cloudStrategy(String id, String name, {DateTime? created}) { + return ( + strategy: _localStrategy(id, name, created: created), + revision: 1, + role: 'owner', + attackLabel: 'Attack', + ); +} + +LibraryFolderRow _folderRow(String id, LibraryWorkspace store) { + final folder = Folder( + name: id, + id: id, + dateCreated: DateTime(2024, 1, 1), + color: FolderColor.blue, + ); + return LibraryFolderRow( + folder: folder, + store: store, + lastUpdated: DateTime(2024, 1, 1), + card: FolderCardViewData.summary(folder: folder, folderCount: 0), + ); +} diff --git a/test/library_navigation_provider_test.dart b/test/library_navigation_provider_test.dart new file mode 100644 index 00000000..ef9cca7c --- /dev/null +++ b/test/library_navigation_provider_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_navigation_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +void main() { + test('My Library uses the cloud store when it is reachable', () { + final container = _container(_cloudReadyState); + + container.read(libraryNavigationProvider).showLibrary(); + + expect(container.read(libraryTabProvider), LibraryTab.library); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.cloud); + }); + + test('My Library falls back to the local store when signed out', () { + final container = _container(_signedOutState); + + container.read(libraryNavigationProvider).showLibrary(); + + expect(container.read(libraryTabProvider), LibraryTab.library); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('Shared needs the cloud and reports when it is not there', () { + final container = _container(_signedOutState); + + final opened = container.read(libraryNavigationProvider).showShared(); + + expect(opened, isFalse); + expect(container.read(libraryTabProvider), LibraryTab.library); + }); + + test('Shared maps to the cloud shared section', () { + final container = _container(_cloudReadyState); + + final opened = container.read(libraryNavigationProvider).showShared(); + + expect(opened, isTrue); + expect(container.read(libraryTabProvider), LibraryTab.shared); + expect( + container.read(cloudLibrarySectionProvider), + CloudLibrarySection.sharedWithMe, + ); + }); + + test('Community is its own tab', () { + final container = _container(_cloudReadyState); + + container.read(libraryNavigationProvider).showCommunity(); + + expect(container.read(libraryTabProvider), LibraryTab.community); + }); +} + +ProviderContainer _container(AppAuthState auth) { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => _FakeAuthProvider(auth)), + ], + ); + addTearDown(container.dispose); + return container; +} + +const _signedOutState = AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _cloudReadyState = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, +); + +class _FakeAuthProvider extends AuthProvider { + _FakeAuthProvider(this._initial); + + final AppAuthState _initial; + + @override + AppAuthState build() => _initial; +} diff --git a/test/library_workspace_provider_test.dart b/test/library_workspace_provider_test.dart new file mode 100644 index 00000000..4460b251 --- /dev/null +++ b/test/library_workspace_provider_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; + +void main() { + test('workspace defaults to local', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthProvider(_signedOutState), + ), + ], + ); + addTearDown(container.dispose); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('cloud cannot be selected when unavailable', () { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith( + () => _FakeAuthProvider(_signedOutState), + ), + ], + ); + addTearDown(container.dispose); + + container + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); + + test('workspace falls back to local when cloud becomes unavailable', () { + final fakeAuth = _FakeAuthProvider(_cloudReadyState); + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => fakeAuth), + ], + ); + addTearDown(container.dispose); + + container + .read(libraryWorkspaceProvider.notifier) + .select(LibraryWorkspace.cloud); + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.cloud); + + fakeAuth.setState(_signedOutState); + + expect(container.read(libraryWorkspaceProvider), LibraryWorkspace.local); + }); +} + +const _signedOutState = AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _cloudReadyState = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, +); + +class _FakeAuthProvider extends AuthProvider { + _FakeAuthProvider(this._initialState); + + final AppAuthState _initialState; + + @override + AppAuthState build() { + return _initialState; + } + + void setState(AppAuthState nextState) { + state = nextState; + } +} diff --git a/test/lineup_add_item_interaction_test.dart b/test/lineup_add_item_interaction_test.dart index 3df642a8..527e5be9 100644 --- a/test/lineup_add_item_interaction_test.dart +++ b/test/lineup_add_item_interaction_test.dart @@ -7,11 +7,13 @@ import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; import 'package:icarus/providers/ability_bar_provider.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/canvas_resize_provider.dart'; import 'package:icarus/providers/interaction_state_provider.dart'; import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/widgets/draggable_widgets/ability/placed_ability_widget.dart'; import 'package:icarus/widgets/draggable_widgets/placed_widget_builder.dart'; import 'package:icarus/widgets/draggable_widgets/agents/agent_widget.dart'; @@ -289,7 +291,15 @@ void main() { expect(container.read(lineUpProvider).placement?.draftAgent, isNull); // The overlay's own origin plus the placer's full-opacity copy. expect(find.byType(AgentWidget), findsNWidgets(2)); - expect(find.byType(Draggable), findsNothing); + // The placer's copy is display only. The committed origin underneath is + // draggable, but the canvas ignores pointers while a lineup is placed. + expect( + find.descendant( + of: find.byType(LineupPositionWidget), + matching: find.byType(Draggable), + ), + findsNothing, + ); }); testWidgets('new-lineup current agent and ability reposition on resize', @@ -410,6 +420,207 @@ void main() { expect(resizedAbilityTopLeft.dy, closeTo(expectedAbilityTopLeft.dy, 0.001)); }); + testWidgets('persistent lineup markers remain draggable after completion', + (tester) async { + final container = _createContainer(); + final group = _breachGroup(); + container + .read(lineUpProvider.notifier) + .fromHive(LineUpGraph.fromLegacyGroups([group])); + + CoordinateSystem(playAreaSize: const Size(900, 600)); + await _pumpHarness( + tester, + container: container, + child: const SizedBox( + width: 900, + height: 600, + child: LineUpOverlay(), + ), + ); + + final abilityFinder = + find.byKey(const ValueKey('lineup-ability-drag-breach-item')); + final initialAbilityTopLeft = tester.getTopLeft(abilityFinder); + const abilityDelta = Offset(-55, 65); + await tester.drag(abilityFinder, abilityDelta); + await tester.pump(); + + var landing = container.read(lineUpProvider).landingById('breach-item')!; + final expectedAbilityPosition = CoordinateSystem.instance + .screenToCoordinate(initialAbilityTopLeft + abilityDelta); + expect( + landing.ability.position.dx, + closeTo(expectedAbilityPosition.dx, 0.001), + ); + expect( + landing.ability.position.dy, + closeTo(expectedAbilityPosition.dy, 0.001), + ); + // One undoable step per move, which is also what marks the strategy dirty. + expect(container.read(actionProvider), hasLength(1)); + + final agentFinder = + find.byKey(const ValueKey('lineup-agent-drag-breach-group')); + final initialAgentTopLeft = tester.getTopLeft(agentFinder); + const agentDelta = Offset(70, 45); + await tester.drag(agentFinder, agentDelta); + await tester.pump(); + + final origin = container.read(lineUpProvider).originById('breach-group')!; + final expectedAgentPosition = CoordinateSystem.instance + .screenToCoordinate(initialAgentTopLeft + agentDelta); + expect(origin.agent.position.dx, closeTo(expectedAgentPosition.dx, 0.001)); + expect(origin.agent.position.dy, closeTo(expectedAgentPosition.dy, 0.001)); + expect(container.read(actionProvider), hasLength(2)); + + // Moving an end keeps the lineup itself: same link between the same ends. + final links = container.read(lineUpProvider).links; + expect(links, hasLength(1)); + expect(links.single.originId, 'breach-group'); + expect(links.single.landingId, 'breach-item'); + landing = container.read(lineUpProvider).landingById('breach-item')!; + expect(landing.ability.lineUpID, 'breach-item'); + }); + + testWidgets('draggable lineup origin still opens its menu on right-click', + (tester) async { + final container = _createContainer(); + final group = _breachGroup(); + container + .read(lineUpProvider.notifier) + .fromHive(LineUpGraph.fromLegacyGroups([group])); + + CoordinateSystem(playAreaSize: const Size(900, 600)); + await _pumpHarness( + tester, + container: container, + child: const SizedBox( + width: 900, + height: 600, + child: LineUpOverlay(), + ), + ); + + await tester.tapAt( + tester.getCenter(find.byType(AgentWidget)), + buttons: kSecondaryButton, + kind: PointerDeviceKind.mouse, + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add lineup')); + await tester.pumpAndSettle(); + + final placement = container.read(lineUpProvider).placement; + expect(placement?.pinnedOriginId, group.id); + expect(placement?.mode, LineUpPlacementMode.fromPinnedOrigin); + expect( + container.read(lineUpProvider).originById('breach-group')!.agent.position, + group.agent.position, + ); + }); + + testWidgets('defense lineup drags persist canonical marker positions', + (tester) async { + final container = _createContainer(); + final group = _breachGroup(); + container + .read(lineUpProvider.notifier) + .fromHive(LineUpGraph.fromLegacyGroups([group])); + container.read(mapProvider.notifier).switchSide(); + + CoordinateSystem(playAreaSize: const Size(900, 600)); + await _pumpHarness( + tester, + container: container, + child: const SizedBox( + width: 900, + height: 600, + child: LineUpOverlay(), + ), + ); + + final coordinateSystem = CoordinateSystem.instance; + final settings = container.read(strategySettingsProvider); + final mapScale = Maps.mapScale[MapValue.bind]!; + final abilityFinder = + find.byKey(const ValueKey('lineup-ability-drag-breach-item')); + final initialAbilityTopLeft = tester.getTopLeft(abilityFinder); + const abilityDelta = Offset(-55, 65); + await tester.drag(abilityFinder, abilityDelta); + await tester.pump(); + + final landing = container.read(lineUpProvider).landingById('breach-item')!; + final expectedAbilityPosition = + storedAbilityPositionForRenderedScreenPosition( + ability: group.items.single.ability.data.abilityData!, + coordinateSystem: coordinateSystem, + renderedScreenPosition: initialAbilityTopLeft + abilityDelta, + mapScale: mapScale, + abilitySize: settings.abilitySize, + isAttack: false, + ); + expect( + landing.ability.position.dx, + closeTo(expectedAbilityPosition.dx, 0.001), + ); + expect( + landing.ability.position.dy, + closeTo(expectedAbilityPosition.dy, 0.001), + ); + + final agentFinder = + find.byKey(const ValueKey('lineup-agent-drag-breach-group')); + final initialAgentTopLeft = tester.getTopLeft(agentFinder); + const agentDelta = Offset(70, 45); + await tester.drag(agentFinder, agentDelta); + await tester.pump(); + + final origin = container.read(lineUpProvider).originById('breach-group')!; + final expectedAgentPosition = storedAgentPositionForRenderedScreenPosition( + coordinateSystem: coordinateSystem, + renderedScreenPosition: initialAgentTopLeft + agentDelta, + agentSize: settings.agentSize, + isAttack: false, + ); + expect(origin.agent.position.dx, closeTo(expectedAgentPosition.dx, 0.001)); + expect(origin.agent.position.dy, closeTo(expectedAgentPosition.dy, 0.001)); + + container.read(mapProvider.notifier).switchSide(); + await tester.pump(); + + final attackAgentTopLeft = screenPositionForWidget( + widget: origin.agent, + coordinateSystem: coordinateSystem, + agentSize: settings.agentSize, + isAttack: true, + ); + final attackAbilityTopLeft = screenPositionForWidget( + widget: landing.ability, + coordinateSystem: coordinateSystem, + mapScale: mapScale, + abilitySize: settings.abilitySize, + isAttack: true, + ); + expect( + tester.getTopLeft(agentFinder).dx, + closeTo(attackAgentTopLeft.dx, 0.001), + ); + expect( + tester.getTopLeft(agentFinder).dy, + closeTo(attackAgentTopLeft.dy, 0.001), + ); + expect( + tester.getTopLeft(abilityFinder).dx, + closeTo(attackAbilityTopLeft.dx, 0.001), + ); + expect( + tester.getTopLeft(abilityFinder).dy, + closeTo(attackAbilityTopLeft.dy, 0.001), + ); + }); + testWidgets('pinned origin rejects dragging any agent', (tester) async { final container = _createContainer(); diff --git a/test/lineup_graph_test.dart b/test/lineup_graph_test.dart index eccce290..ef01551e 100644 --- a/test/lineup_graph_test.dart +++ b/test/lineup_graph_test.dart @@ -93,7 +93,6 @@ void main() { expect(second.originId, isNot(first.originId)); expect(state.linksToLanding(first.landingId), hasLength(2)); }); - }); group('deletion and undo', () { @@ -140,13 +139,13 @@ void main() { final addSecond = container.read(actionProvider).last; expect(addSecond.id, second.id); - notifier.undoAction(addSecond); + container.read(actionProvider.notifier).undoAction(); var state = container.read(lineUpProvider); expect(state.links.map((link) => link.id), [first.id]); expect(state.origins.map((origin) => origin.id), [first.originId]); expect(state.landings.single.id, first.landingId); - notifier.redoAction(addSecond); + container.read(actionProvider.notifier).redoAction(); state = container.read(lineUpProvider); expect(state.links, hasLength(2)); expect(state.origins, hasLength(2)); @@ -167,7 +166,7 @@ void main() { expect(container.read(lineUpProvider).links, isEmpty); expect(container.read(lineUpProvider).landings, isEmpty); - notifier.undoAction(deletion); + container.read(actionProvider.notifier).undoAction(); final state = container.read(lineUpProvider); expect(state.links.map((link) => link.id), [first.id, second.id]); expect(state.origins.single.id, first.originId); @@ -217,7 +216,8 @@ void main() { final groups = container.read(lineUpProvider).graph.toLegacyGroups(); - expect(groups.map((group) => group.id), [first.originId, second.originId]); + expect( + groups.map((group) => group.id), [first.originId, second.originId]); for (final group in groups) { expect(group.agent.lineUpID, group.id); expect(group.items.single.ability.id, 'b1'); diff --git a/test/lineup_group_changes_test.dart b/test/lineup_group_changes_test.dart new file mode 100644 index 00000000..3c9bbd24 --- /dev/null +++ b/test/lineup_group_changes_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; + +LineUpOrigin _origin(String id, {Offset position = const Offset(10, 20)}) => + LineUpOrigin( + id: id, + agent: PlacedAgent( + id: 'agent-$id', + type: AgentType.brimstone, + position: position, + lineUpID: id, + ), + ); + +LineUpLanding _landing(String id, {Offset position = const Offset(30, 40)}) => + LineUpLanding( + id: id, + ability: PlacedAbility( + id: 'ability-$id', + data: AgentData.agents[AgentType.brimstone]!.abilities[2], + position: position, + lineUpID: id, + ), + ); + +/// Three smokes from o1, o2, o3 onto one landing; k3 has no name. +LineUpGraph _group({ + Offset landingAt = const Offset(30, 40), + String k1Notes = '', + List extra = const [], + bool withoutK2 = false, +}) => + LineUpGraph( + origins: [_origin('o1'), _origin('o2'), _origin('o3')], + landings: [_landing('l1', position: landingAt)], + links: [ + LineUpLink( + id: 'k1', + originId: 'o1', + landingId: 'l1', + name: 'B Main 1', + notes: k1Notes, + ), + if (!withoutK2) + LineUpLink( + id: 'k2', originId: 'o2', landingId: 'l1', name: 'B Main 2'), + LineUpLink(id: 'k3', originId: 'o3', landingId: 'l1'), + ...extra, + ], + ); + +Map _described(List changes) => { + for (final change in changes) change.label: change.description, + }; + +void main() { + test('nothing changed lists nothing', () { + expect(lineupChanges(from: _group(), to: _group()), isEmpty); + }); + + test('each changed lineup is listed once, with everything that changed', () { + final changes = lineupChanges( + from: _group(), + to: _group(landingAt: const Offset(99, 99), k1Notes: 'jump throw'), + ); + + // Moving the shared landing moves every lineup on it. + expect(_described(changes), { + 'B Main 1': 'notes edited, landing moved', + 'B Main 2': 'landing moved', + 'Brimstone lineup': 'landing moved', + }); + }); + + test('added and deleted lineups are named as such', () { + final changes = lineupChanges( + from: _group(), + to: _group( + withoutK2: true, + extra: [ + LineUpLink(id: 'k4', originId: 'o1', landingId: 'l1', name: 'New'), + ], + ), + ); + + expect(_described(changes), {'New': 'added', 'B Main 2': 'deleted'}); + expect(changes.last.label, 'B Main 2', reason: 'deletions come last'); + }); + + test('a deleted group lists every lineup as deleted', () { + final changes = lineupChanges(from: _group(), to: LineUpGraph.empty); + + expect(changes.map((change) => change.description).toSet(), {'deleted'}); + expect(changes, hasLength(3)); + }); + + test('a fork has fresh ids everywhere and the same lineups', () { + final original = _group(k1Notes: 'jump throw'); + final fork = forkLineUpGraph(original); + + final originalIds = { + for (final origin in original.origins) ...[origin.id, origin.agent.id], + for (final landing in original.landings) ...[ + landing.id, + landing.ability.id, + ], + for (final link in original.links) link.id, + }; + final forkIds = { + for (final origin in fork.origins) ...[origin.id, origin.agent.id], + for (final landing in fork.landings) ...[landing.id, landing.ability.id], + for (final link in fork.links) link.id, + }; + expect(forkIds.intersection(originalIds), isEmpty); + expect(forkIds, hasLength(originalIds.length)); + + // The fork is wired like the original: three lineups on one landing, + // each marker naming its own spot. + expect(fork.links.map((link) => link.landingId).toSet(), { + fork.landings.single.id, + }); + for (final origin in fork.origins) { + expect(origin.agent.lineUpID, origin.id); + } + expect(fork.landings.single.ability.lineUpID, fork.landings.single.id); + + // And otherwise the same: nothing a user sees differs. + String described(LineUpGraph graph) => [ + for (final link in graph.links) + '${link.name}|${link.notes}|' + '${graph.origins.firstWhere((o) => o.id == link.originId).agent.position}|' + '${graph.landings.firstWhere((l) => l.id == link.landingId).ability.position}', + ].join(';'); + expect(described(fork), described(original)); + }); +} diff --git a/test/lineup_group_migration_test.dart b/test/lineup_group_migration_test.dart index 2da14883..532656c5 100644 --- a/test/lineup_group_migration_test.dart +++ b/test/lineup_group_migration_test.dart @@ -6,8 +6,9 @@ import 'package:icarus/const/settings.dart'; import 'package:icarus/const/line_provider.dart'; import 'package:icarus/migrations/lineup_group_migration.dart'; import 'package:icarus/providers/strategy_page.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; StrategyData _buildStrategyWithGroup({ required String? agentLineUpId, @@ -63,13 +64,14 @@ StrategyData _buildStrategyWithGroup({ void main() { group('LineUpGroupMigration', () { - test('does not rewrite strategy when lineup ids are already normalized', () { + test('does not rewrite strategy when lineup ids are already normalized', + () { final strategy = _buildStrategyWithGroup( agentLineUpId: 'group-1', abilityLineUpId: 'group-1', ); - final migrated = StrategyProvider.migrateLineUpGroups(strategy); + final migrated = StrategyMigrator.migrateLineUpGroups(strategy); expect(identical(migrated, strategy), isTrue); }); @@ -80,7 +82,7 @@ void main() { abilityLineUpId: null, ); - final migrated = StrategyProvider.migrateToCurrentVersion(strategy); + final migrated = StrategyMigrator.migrateToCurrentVersion(strategy); final group = migrated.pages.single.lineUpGroups.single; final graph = migrated.pages.single.lineUpGraph; diff --git a/test/local_library_policy_test.dart b/test/local_library_policy_test.dart new file mode 100644 index 00000000..39a247a6 --- /dev/null +++ b/test/local_library_policy_test.dart @@ -0,0 +1,130 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; + +/// Where the platform policy hides the on-device library, nothing may read +/// or rewrite it: its records wait, untouched, for local access to return. +void main() { + late Directory tempDir; + late Box strategies; + + setUpAll(() => registerIcarusAdapters(Hive)); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-local-policy-'); + Hive.init(tempDir.path); + strategies = await Hive.openBox(HiveBoxNames.strategiesBox); + final old = _oldStrategy(); + await strategies.put(old.id, old); + await strategies.flush(); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + List boxBytes() => File( + '${tempDir.path}/${HiveBoxNames.strategiesBox.toLowerCase()}.hive', + ).readAsBytesSync(); + + group('startup migration', () { + test('leaves hidden local strategies byte-identical on the web beta', + () async { + final before = boxBytes(); + + await StrategyMigrator.migrateLocalLibrary(PlatformPolicy.webBeta); + await strategies.flush(); + + expect(boxBytes(), before); + expect(strategies.get('old-plan')!.versionNumber, 20); + }); + + test('still migrates the local library on desktop', () async { + final before = boxBytes(); + + await StrategyMigrator.migrateLocalLibrary(PlatformPolicy.desktop); + await strategies.flush(); + + // The control: the same record does migrate where local is allowed, + // so the web assertion above is not vacuous. + expect(boxBytes(), isNot(before)); + expect(strategies.get('old-plan')!.versionNumber, greaterThan(20)); + }); + }); + + group('strategy provider on the web beta', () { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer( + overrides: [ + platformPolicyProvider.overrideWithValue(PlatformPolicy.webBeta), + ], + ); + }); + + tearDown(() => container.dispose()); + + test('refuses to open a local strategy and leaves it untouched', () async { + final before = boxBytes(); + + await expectLater( + container.read(strategyProvider.notifier).loadFromHive('old-plan'), + throwsStateError, + ); + + expect(container.read(strategyProvider).strategyId, isNull); + await strategies.flush(); + expect(boxBytes(), before); + }); + + test('refuses to save a local strategy', () async { + final before = boxBytes(); + + await expectLater( + container.read(strategyProvider.notifier).saveToHive('old-plan'), + throwsStateError, + ); + + await strategies.flush(); + expect(boxBytes(), before); + }); + }); +} + +StrategyData _oldStrategy() { + return StrategyData( + id: 'old-plan', + name: 'Old Plan', + mapData: MapValue.ascent, + versionNumber: 20, + lastEdited: DateTime.utc(2025, 1, 1), + folderID: null, + pages: [ + StrategyPage( + id: 'page-1', + sortIndex: 0, + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + isAttack: true, + settings: StrategySettings(), + ), + ], + ); +} diff --git a/test/macos_release_entitlements_test.dart b/test/macos_release_entitlements_test.dart new file mode 100644 index 00000000..59237842 --- /dev/null +++ b/test/macos_release_entitlements_test.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('release sandbox permits cloud access and user-selected exports', () { + final entitlements = + File('macos/Runner/Release.entitlements').readAsStringSync(); + bool isEnabled(String key) => RegExp( + '${RegExp.escape(key)}\\s*', + ).hasMatch(entitlements); + + expect(isEnabled('com.apple.security.network.client'), isTrue); + expect( + isEnabled('com.apple.security.files.user-selected.read-write'), + isTrue, + ); + }); +} diff --git a/test/mouse_navigation_test.dart b/test/mouse_navigation_test.dart index b3b18052..dbe58d46 100644 --- a/test/mouse_navigation_test.dart +++ b/test/mouse_navigation_test.dart @@ -1,8 +1,18 @@ +import 'dart:io'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/config/platform_policy.dart'; import 'package:icarus/const/app_navigator.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/mouse_navigation.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -120,4 +130,111 @@ void main() { expect(find.text('auxiliary'), findsOneWidget); expect(find.text('library'), findsNothing); }); + + group('a local and a cloud strategy sharing an id', () { + late Directory tempDir; + + setUpAll(() => registerIcarusAdapters(Hive)); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-mouse-nav-'); + Hive.init(tempDir.path); + final box = await Hive.openBox(HiveBoxNames.strategiesBox); + // Leaving a strategy resets the agent filter and checks the outboxes. + await Hive.openBox(HiveBoxNames.favoriteAgentsBox); + await Hive.openBox(HiveBoxNames.strategyOutboxBox); + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + // The local copy an upload leaves behind keeps the cloud strategy's id. + await box.put( + 'shared-id', + StrategyData( + id: 'shared-id', + name: 'Local copy', + mapData: MapValue.ascent, + versionNumber: 1, + folderID: null, + pages: const [], + lastEdited: DateTime.utc(2026, 1, 1), + ), + ); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + // The strategy open when back is pressed is local in both cases: leaving + // a cloud strategy runs the outbox guard, which needs a live Convex + // client. + for (final (recorded, then) in const [ + (StrategySource.cloud, StrategySource.local), + (StrategySource.local, StrategySource.local), + ]) { + testWidgets('back reopens the ${recorded.name} one it came from', + (tester) async { + final strategies = _RecordingStrategies(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + platformPolicyProvider.overrideWithValue(PlatformPolicy.desktop), + isCloudWorkspaceAvailableProvider.overrideWithValue(true), + strategyProvider.overrideWith(() => strategies), + ], + child: ShadApp( + navigatorKey: appNavigatorKey, + navigatorObservers: [mouseNavigationRouteObserver], + builder: (_, child) => MouseNavigation(child: child!), + home: const Scaffold(body: Text('library')), + ), + ), + ); + + strategies.show('shared-id', recorded); + await tester.pump(); + strategies.show('other-id', then); + await tester.pump(); + + await tester.sendEventToBinding( + const PointerDownEvent( + kind: PointerDeviceKind.mouse, + buttons: kBackMouseButton, + position: Offset(400, 300), + ), + ); + await tester.pumpAndSettle(); + + expect(strategies.opened, ['${recorded.name}:shared-id']); + }); + } + }); +} + +/// Records which store each open goes through, without touching either. +class _RecordingStrategies extends StrategyProvider { + final opened = []; + + @override + StrategyState build() => const StrategyState(); + + void show(String id, StrategySource source) { + state = StrategyState( + strategyId: id, + strategyName: id, + source: source, + isOpen: true, + ); + } + + @override + Future loadFromHive(String id) async { + opened.add('local:$id'); + show(id, StrategySource.local); + } + + @override + Future openCloudStrategy(String id) async { + opened.add('cloud:$id'); + show(id, StrategySource.cloud); + } } diff --git a/test/mp4_box_test_support.dart b/test/mp4_box_test_support.dart new file mode 100644 index 00000000..35afcedc --- /dev/null +++ b/test/mp4_box_test_support.dart @@ -0,0 +1,169 @@ +import 'dart:typed_data'; + +/// One parsed ISO-BMFF box. +class Mp4Box { + Mp4Box(this.type, this.start, this.size, this.headerSize); + + final String type; + final int start; + final int size; + final int headerSize; + + int get end => start + size; + + Uint8List payload(Uint8List file) => + file.sublist(start + headerSize, start + size); +} + +/// Reads back the boxes an MP4 writer produced, for assertions in tests. +/// Pure Dart, so the VM and browser tests share it. +class Mp4Boxes { + Mp4Boxes(this.file) : _data = ByteData.sublistView(file); + + final Uint8List file; + final ByteData _data; + + static const _containers = {'moov', 'trak', 'mdia', 'minf', 'dinf', 'stbl'}; + + List get topLevelTypes => + [for (final box in _parse(0, file.length)) box.type]; + + List _parse(int start, int end) { + final boxes = []; + var offset = start; + while (offset < end) { + var size = _data.getUint32(offset); + final type = String.fromCharCodes(file.sublist(offset + 4, offset + 8)); + var headerSize = 8; + if (size == 1) { + size = _data.getUint32(offset + 8) * 0x100000000 + + _data.getUint32(offset + 12); + headerSize = 16; + } + if (size < headerSize || offset + size > end) { + throw FormatException('Bad $type box size $size at $offset'); + } + boxes.add(Mp4Box(type, offset, size, headerSize)); + offset += size; + } + return boxes; + } + + /// Follows [path] from the top level, e.g. `['moov', 'trak', 'tkhd']`. + Mp4Box find(List path) { + var boxes = _parse(0, file.length); + late Mp4Box found; + for (var i = 0; i < path.length; i++) { + found = boxes.firstWhere( + (box) => box.type == path[i], + orElse: () => throw StateError('No ${path.take(i + 1).join('/')}'), + ); + if (i + 1 < path.length) { + if (!_containers.contains(found.type)) { + throw StateError('${found.type} is not a container'); + } + boxes = _parse(found.start + found.headerSize, found.end); + } + } + return found; + } + + Mp4Box? _maybeFind(List path) { + try { + return find(path); + } on StateError { + return null; + } + } + + static const _stbl = ['moov', 'trak', 'mdia', 'minf', 'stbl']; + + /// Box payload offset past the version/flags word of a full box. + int _fullBoxBody(Mp4Box box) => box.start + box.headerSize + 4; + + List _u32Table(Mp4Box box, {required int skip, int stride = 1}) { + final body = _fullBoxBody(box); + final count = _data.getUint32(body + skip * 4); + final first = body + (skip + 1) * 4; + return [ + for (var i = 0; i < count * stride; i++) _data.getUint32(first + i * 4), + ]; + } + + List chunkOffsets() => _u32Table(find([..._stbl, 'stco']), skip: 0); + + List sampleSizes() { + final stsz = find([..._stbl, 'stsz']); + final fixed = _data.getUint32(_fullBoxBody(stsz)); + if (fixed != 0) throw UnimplementedError('Fixed-size samples'); + return _u32Table(stsz, skip: 1); + } + + /// `[sample_count, sample_delta]` runs. + List> stts() { + final flat = _u32Table(find([..._stbl, 'stts']), skip: 0, stride: 2); + return [ + for (var i = 0; i < flat.length; i += 2) [flat[i], flat[i + 1]], + ]; + } + + List sampleDurations() => [ + for (final run in stts()) + for (var i = 0; i < run[0]; i++) run[1], + ]; + + /// 1-based keyframe numbers, or null when stss is absent (all sync). + List? syncSamples() { + final stss = _maybeFind([..._stbl, 'stss']); + return stss == null ? null : _u32Table(stss, skip: 0); + } + + int samplesPerChunk() { + final stsc = find([..._stbl, 'stsc']); + final body = _fullBoxBody(stsc); + if (_data.getUint32(body) != 1) throw UnimplementedError('Several runs'); + return _data.getUint32(body + 8); + } + + (int, int) _timescaleAndDuration(List path) { + final box = find(path); + final body = _fullBoxBody(box); + final version = file[box.start + box.headerSize]; + if (version == 1) { + return ( + _data.getUint32(body + 16), + _data.getUint32(body + 20) * 0x100000000 + _data.getUint32(body + 24), + ); + } + return (_data.getUint32(body + 8), _data.getUint32(body + 12)); + } + + int mediaTimescale() => + _timescaleAndDuration(['moov', 'trak', 'mdia', 'mdhd']).$1; + + int mediaDuration() => + _timescaleAndDuration(['moov', 'trak', 'mdia', 'mdhd']).$2; + + int movieDuration() => _timescaleAndDuration(['moov', 'mvhd']).$2; + + /// tkhd width and height, whole pixels of the 16.16 values. + (int, int) trackSize() { + final tkhd = find(['moov', 'trak', 'tkhd']); + return ( + _data.getUint32(tkhd.end - 8) ~/ 0x10000, + _data.getUint32(tkhd.end - 4) ~/ 0x10000, + ); + } + + /// The single entry inside stsd. + Mp4Box sampleEntry() { + final stsd = find([..._stbl, 'stsd']); + final entries = _parse(_fullBoxBody(stsd) + 4, stsd.end); + return entries.single; + } + + /// Child boxes of a box whose own fields take [entryHeaderSize] bytes after + /// its header (78 for a visual sample entry). + List childrenOf(Mp4Box box, {required int entryHeaderSize}) => + _parse(box.start + box.headerSize + entryHeaderSize, box.end); +} diff --git a/test/mp4_muxer_test.dart b/test/mp4_muxer_test.dart new file mode 100644 index 00000000..861aeca5 --- /dev/null +++ b/test/mp4_muxer_test.dart @@ -0,0 +1,352 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/services/video_export/mp4_muxer.dart'; + +import 'mp4_box_test_support.dart'; + +void main() { + group('Mp4H264Muxer box structure', () { + // Fake AVCC samples: the muxer never looks inside them. + Uint8List sample(int seed, int length) => Uint8List.fromList( + List.generate(length, (i) => (seed * 31 + i) & 0xFF)); + final avcC = Uint8List.fromList( + [1, 0x64, 0x00, 0x28, 0xFF, 0xE1, 0, 2, 0x67, 0x64, 1, 0, 2, 0x68, 0xEE], + ); + + test('lays out ftyp, moov, mdat with the chunk offset on sample one', () { + final muxer = Mp4H264Muxer(width: 1920, height: 1080); + final samples = [ + sample(1, 100), + sample(2, 37), + sample(3, 51), + sample(4, 9), + sample(5, 64), + ]; + final durations = [270000, 3000, 3000, 1500, 3000]; + final keys = [true, false, false, true, false]; + for (var i = 0; i < samples.length; i++) { + muxer.addSample(samples[i], + duration: durations[i], isKeyFrame: keys[i]); + } + final file = muxer.finish(avcDecoderConfig: avcC); + final mp4 = Mp4Boxes(file); + + expect(mp4.topLevelTypes, ['ftyp', 'moov', 'mdat']); + + final ftyp = mp4.find(['ftyp']); + expect(String.fromCharCodes(ftyp.payload(file).sublist(0, 4)), 'isom'); + + final offsets = mp4.chunkOffsets(); + expect(offsets, hasLength(1)); + final mdat = mp4.find(['mdat']); + expect(offsets.single, mdat.start + mdat.headerSize); + var cursor = offsets.single; + final sizes = mp4.sampleSizes(); + expect(sizes, [for (final s in samples) s.length]); + for (var i = 0; i < samples.length; i++) { + expect(file.sublist(cursor, cursor + sizes[i]), samples[i]); + cursor += sizes[i]; + } + expect(cursor, file.length); + + expect(mp4.sampleDurations(), durations); + expect(mp4.stts(), [ + [1, 270000], + [2, 3000], + [1, 1500], + [1, 3000], + ]); + expect(mp4.syncSamples(), [1, 4]); + expect(mp4.mediaTimescale(), 90000); + expect(mp4.mediaDuration(), 280500); + expect(mp4.movieDuration(), 280500); + expect(mp4.trackSize(), (1920, 1080)); + expect(mp4.samplesPerChunk(), samples.length); + expect( + mp4.find(['moov', 'trak', 'mdia', 'hdlr']).payload(file).sublist(8, 12), + 'vide'.codeUnits, + ); + + final avc1 = mp4.sampleEntry(); + expect(avc1.type, 'avc1'); + final avcCBox = mp4.childrenOf(avc1, entryHeaderSize: 78).single; + expect(avcCBox.type, 'avcC'); + expect(avcCBox.payload(file), avcC); + }); + + test('describes the samples\' colour after avcC when told it', () { + final muxer = Mp4H264Muxer(width: 640, height: 360) + ..addSample(sample(1, 10), duration: 1000, isKeyFrame: true); + final file = muxer.finish( + avcDecoderConfig: avcC, + color: const Mp4ColorInfo( + primaries: 1, + transfer: 13, + matrix: 1, + fullRange: true, + ), + ); + final mp4 = Mp4Boxes(file); + final children = mp4.childrenOf(mp4.sampleEntry(), entryHeaderSize: 78); + expect([for (final box in children) box.type], ['avcC', 'colr']); + expect( + children.last.payload(file), + [...'nclx'.codeUnits, 0, 1, 0, 13, 0, 1, 0x80], + ); + + final undescribed = Mp4Boxes( + (Mp4H264Muxer(width: 640, height: 360) + ..addSample(sample(1, 10), duration: 1000, isKeyFrame: true)) + .finish(avcDecoderConfig: avcC), + ); + expect( + [ + for (final box in undescribed.childrenOf( + undescribed.sampleEntry(), + entryHeaderSize: 78, + )) + box.type, + ], + ['avcC'], + ); + }); + + test('omits stss when every sample is a keyframe', () { + final muxer = Mp4H264Muxer(width: 640, height: 360, timescale: 30000) + ..addSample(sample(1, 10), duration: 1000, isKeyFrame: true) + ..addSample(sample(2, 10), duration: 1000, isKeyFrame: true); + final mp4 = Mp4Boxes(muxer.finish(avcDecoderConfig: avcC)); + expect(mp4.syncSamples(), isNull); + expect(mp4.stts(), [ + [2, 1000], + ]); + }); + + test('refuses an empty file and a non-keyframe start', () { + expect( + () => Mp4H264Muxer(width: 640, height: 360) + .finish(avcDecoderConfig: avcC), + throwsStateError, + ); + expect( + () => Mp4H264Muxer(width: 640, height: 360) + .addSample(sample(1, 10), duration: 1, isKeyFrame: false), + throwsStateError, + ); + expect( + () => Mp4H264Muxer(width: 640, height: 360) + .addSample(sample(1, 10), duration: 0, isKeyFrame: true), + throwsArgumentError, + ); + }); + }); + + group('Mp4H264Muxer with a real H.264 stream', () { + final ffmpegMissing = _ffmpegMissingReason(); + + test( + 'produces a file ffprobe and ffmpeg read back exactly', + () async { + final dir = await Directory.systemTemp.createTemp('icarus_mp4_muxer_'); + addTearDown(() => dir.delete(recursive: true)); + final annexBPath = '${dir.path}${Platform.pathSeparator}in.h264'; + final outPath = '${dir.path}${Platform.pathSeparator}out.mp4'; + + // No B-frames (the muxer writes no composition offsets), an access + // unit delimiter before every frame so the stream splits cleanly, and + // an IDR every 5 frames so stss has several entries. + final encode = await Process.run('ffmpeg', [ + '-v', 'error', '-y', // + '-f', 'lavfi', '-i', 'testsrc=size=320x240:rate=10', + '-frames:v', '20', + '-c:v', 'libx264', '-pix_fmt', 'yuv420p', + '-x264-params', 'aud=1:bframes=0:keyint=5:min-keyint=5:scenecut=0', + '-bsf:v', 'h264_mp4toannexb', + '-f', 'h264', annexBPath, + ]); + expect(encode.exitCode, 0, reason: '${encode.stderr}'); + + final stream = _AnnexBStream.parse( + await File(annexBPath).readAsBytes(), + ); + expect(stream.accessUnits, hasLength(20)); + + final muxer = Mp4H264Muxer(width: 320, height: 240); + // First frame held for 3 s, the rest at 1/10 s: 3 + 19 * 0.1 = 4.9 s. + for (var i = 0; i < stream.accessUnits.length; i++) { + final unit = stream.accessUnits[i]; + muxer.addSample( + unit.avcc, + duration: i == 0 ? 270000 : 9000, + isKeyFrame: unit.isKeyFrame, + ); + } + // libx264 writes no colour description; the container says the + // samples are full range, and players must believe it. + final bytes = muxer.finish( + avcDecoderConfig: stream.avcDecoderConfig, + color: const Mp4ColorInfo( + primaries: 1, + transfer: 1, + matrix: 1, + fullRange: true, + ), + ); + await File(outPath).writeAsBytes(bytes); + + expect(Mp4Boxes(bytes).syncSamples(), [1, 6, 11, 16]); + + final probe = await Process.run('ffprobe', [ + '-v', 'error', // + '-count_frames', + '-show_streams', '-show_format', + outPath, + ]); + expect(probe.exitCode, 0, reason: '${probe.stderr}'); + final fields = _probeFields(probe.stdout as String); + expect(fields['codec_name'], 'h264'); + expect(fields['color_range'], 'pc'); + expect(fields['color_space'], 'bt709'); + expect(fields['width'], '320'); + expect(fields['height'], '240'); + expect(fields['nb_read_frames'], '20'); + expect(fields['nb_frames'], '20'); + expect(double.parse(fields['format.duration']!), closeTo(4.9, 0.001)); + expect(double.parse(fields['stream.duration']!), closeTo(4.9, 0.001)); + + final decode = await Process.run('ffmpeg', [ + '-v', 'error', '-i', outPath, '-f', 'null', '-', // + ]); + expect(decode.exitCode, 0); + expect((decode.stderr as String).trim(), isEmpty); + }, + skip: ffmpegMissing, + timeout: const Timeout(Duration(minutes: 2)), + ); + }); +} + +/// Null when ffmpeg (with libx264) and ffprobe both run, otherwise why the +/// test is skipped. +String? _ffmpegMissingReason() { + for (final tool in ['ffmpeg', 'ffprobe']) { + try { + final result = Process.runSync(tool, ['-version']); + if (result.exitCode != 0) return '$tool -version failed'; + } on ProcessException { + return '$tool is not on PATH'; + } + } + final encoders = Process.runSync('ffmpeg', ['-hide_banner', '-encoders']); + if (!'${encoders.stdout}'.contains('libx264')) { + return 'ffmpeg has no libx264 encoder'; + } + return null; +} + +/// Parses ffprobe's default output into `key` (stream fields) plus +/// `stream.duration` / `format.duration`, which share a name. +Map _probeFields(String output) { + final fields = {}; + var section = ''; + for (final raw in output.split(RegExp(r'\r?\n'))) { + final line = raw.trim(); + if (line == '[STREAM]') section = 'stream'; + if (line == '[FORMAT]') section = 'format'; + final eq = line.indexOf('='); + if (eq <= 0) continue; + final key = line.substring(0, eq); + final value = line.substring(eq + 1); + if (key == 'duration') { + fields['$section.duration'] = value; + } else { + fields.putIfAbsent(key, () => value); + } + } + return fields; +} + +class _AccessUnit { + _AccessUnit(this.avcc, this.isKeyFrame); + final Uint8List avcc; + final bool isKeyFrame; +} + +/// An Annex-B H.264 stream split on its access unit delimiters. +class _AnnexBStream { + _AnnexBStream(this.accessUnits, this.avcDecoderConfig); + + final List<_AccessUnit> accessUnits; + final Uint8List avcDecoderConfig; + + static _AnnexBStream parse(Uint8List bytes) { + final nals = _splitNalUnits(bytes); + Uint8List? sps; + Uint8List? pps; + final units = <_AccessUnit>[]; + List? current; + var currentIsKey = false; + + void close() { + final nalsInUnit = current; + if (nalsInUnit == null || nalsInUnit.isEmpty) return; + final out = BytesBuilder(); + for (final nal in nalsInUnit) { + out + ..add((ByteData(4)..setUint32(0, nal.length)).buffer.asUint8List()) + ..add(nal); + } + units.add(_AccessUnit(out.takeBytes(), currentIsKey)); + } + + for (final nal in nals) { + final type = nal[0] & 0x1F; + switch (type) { + case 9: // access unit delimiter + close(); + current = []; + currentIsKey = false; + case 7: + sps ??= nal; + case 8: + pps ??= nal; + default: + if (type == 5) currentIsKey = true; + (current ??= []).add(nal); + } + } + close(); + + final s = sps!; + final p = pps!; + final config = BytesBuilder() + ..add([1, s[1], s[2], s[3], 0xFF, 0xE1]) + ..add([s.length >> 8, s.length & 0xFF]) + ..add(s) + ..add([1, p.length >> 8, p.length & 0xFF]) + ..add(p); + return _AnnexBStream(units, config.takeBytes()); + } + + static List _splitNalUnits(Uint8List bytes) { + final starts = []; // index of the first byte after each start code + for (var i = 0; i + 2 < bytes.length; i++) { + if (bytes[i] == 0 && bytes[i + 1] == 0 && bytes[i + 2] == 1) { + starts.add(i + 3); + i += 2; + } + } + final nals = []; + for (var n = 0; n < starts.length; n++) { + var end = n + 1 < starts.length ? starts[n + 1] - 3 : bytes.length; + // A 4-byte start code leaves a zero byte on the previous NAL. + while (end > starts[n] && bytes[end - 1] == 0) { + end--; + } + nals.add(Uint8List.sublistView(bytes, starts[n], end)); + } + return nals; + } +} diff --git a/test/page_screenshot_test.dart b/test/page_screenshot_test.dart new file mode 100644 index 00000000..beb390d1 --- /dev/null +++ b/test/page_screenshot_test.dart @@ -0,0 +1,493 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/screenshot/capture_images.dart'; +import 'package:icarus/screenshot/page_screenshot.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:image/image.dart' as img; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' show AuthState, Session; + +const _strategyId = 'cloud-strategy'; +const _pageId = 'page-1'; +const _imageId = 'image-1'; +const _imageUrl = 'https://media.example.com/image-1.png'; + +/// A cloud strategy open in the editor. Cloud strategies are never in the +/// local Hive box, which is what a capture used to read. +class _CloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: _strategyId, + strategyName: 'Cloud strat', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); +} + +class _CloudSnapshot extends RemoteEditorSnapshotNotifier { + _CloudSnapshot({required this.imageUrl}); + + final String? imageUrl; + + @override + Future build() async { + final now = DateTime.utc(2026); + final page = RemotePage( + publicId: _pageId, + strategyPublicId: _strategyId, + name: 'Retake B', + sortIndex: 0, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: _strategyId, + name: 'Cloud strat', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + role: 'owner', + ), + pages: [page], + ), + activePage: RemotePageSnapshot( + page: page, + content: RemotePageContent(revision: 1, createdAt: now, updatedAt: now), + elements: const [], + lineups: const [], + assetsById: { + _imageId: RemoteImageAsset( + publicId: _imageId, + fileExtension: '.png', + width: 64, + height: 36, + url: imageUrl, + legacyStoragePath: null, + uploadStatus: imageUrl == null ? 'pending' : 'active', + ), + }, + ), + ); + } +} + +/// Records every call a capture makes to the app's one Convex client. +class _RecordingConvexAuth extends Fake implements AuthProviderConvexApi { + final calls = []; + + @override + Stream get authState => const Stream.empty(); + + @override + bool get isAuthenticated => true; + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + calls.add('setAuthWithRefresh'); + return _RecordingHandle(calls); + } + + @override + Future clearAuth() async => calls.add('clearAuth'); +} + +class _RecordingHandle implements AuthProviderAuthHandle { + _RecordingHandle(this.calls); + final List calls; + + @override + void dispose() => calls.add('dispose'); +} + +class _NoSupabase extends Fake implements AuthProviderSupabaseApi { + @override + Session? get currentSession => null; + + @override + Stream get onAuthStateChange => const Stream.empty(); +} + +/// A save dialog the test closes when it chooses. +class _PendingPicker extends FilePicker { + final saves = []; + final closed = Completer(); + + @override + Future saveFile({ + String? dialogTitle, + String? fileName, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Uint8List? bytes, + bool lockParentWindow = false, + }) { + saves.add(bytes); + return closed.future; + } +} + +class _IdleOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState(); +} + +class _NoUploads extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => + const CloudMediaUploadQueueState(jobs: [], isProcessing: false); +} + +/// Pure magenta, a colour no map or marker draws. +final _magentaPng = Uint8List.fromList( + img.encodePng( + img.fill( + img.Image(width: 64, height: 36), + color: img.ColorRgb8(255, 0, 255), + ), + ), +); + +int _magentaPixels(Uint8List png) { + final decoded = img.decodePng(png)!; + var count = 0; + for (final pixel in decoded) { + if (pixel.r > 240 && pixel.g < 20 && pixel.b > 240) count++; + } + return count; +} + +void main() { + late Directory hiveDir; + + setUpAll(() async { + hiveDir = await Directory.systemTemp.createTemp('icarus_page_screenshot'); + Hive.init(hiveDir.path); + registerIcarusAdapters(Hive); + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await MapThemeProfilesProvider.bootstrap(); + }); + + tearDownAll(() async { + await Hive.close(); + await hiveDir.delete(recursive: true); + }); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1600, 900)); + CoordinateSystem.instance.setIsScreenshot(false); + }); + + /// Opens the cloud page in a web-like editor (no image files on this + /// device) and returns a ref to capture it with. + Future openCloudPage( + WidgetTester tester, { + required String? imageUrl, + }) async { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_CloudStrategy.new), + remoteEditorSnapshotProvider + .overrideWith(() => _CloudSnapshot(imageUrl: imageUrl)), + imageFilesOnDeviceProvider.overrideWithValue(false), + pendingMediaBytesStoreProvider + .overrideWithValue(MemoryPendingMediaBytesStore()), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudMediaUploadQueueProvider.overrideWith(_NoUploads.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + ]); + addTearDown(container.dispose); + await tester.runAsync( + () => container.read(remoteEditorSnapshotProvider.future), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + container + .read(strategyPageSessionProvider) + .copyWith(activePageId: _pageId), + ); + container.read(placedImageProvider.notifier).fromHive([ + PlacedImage( + position: const Offset(500, 500), + id: _imageId, + aspectRatio: 64 / 36, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + sizeVersion: PlacedImage.currentSizeVersion, + ), + ]); + + late WidgetRef ref; + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: Consumer(builder: (context, widgetRef, _) { + ref = widgetRef; + return const SizedBox.shrink(); + }), + )); + return ref; + } + + /// Runs [capture] as the app would: on real time, with the app's frames + /// still coming. Offscreen layout builders wait for the app's next frame + /// before rebuilding, so a capture that never sees one never paints what + /// arrived after its first build. + Future captureWithFrames( + WidgetTester tester, + Future Function() capture, + ) async { + Object? outcome; + var done = false; + await tester.runAsync(() async { + unawaited( + capture().then((png) => outcome = png, onError: (Object error) { + outcome = error; + }).whenComplete(() => done = true), + ); + }); + while (!done) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 16)), + ); + await tester.pump(); + } + return outcome; + } + + testWidgets('a cloud page captures with its image fetched from the cloud URL', + (tester) async { + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + final requested = []; + + final png = await captureWithFrames( + tester, + () => http.runWithClient( + () => captureEditorPage(ref), + () => MockClient((request) async { + requested.add(request.url); + return http.Response.bytes(_magentaPng, 200); + }), + ), + ); + + expect(png, isA()); + expect(requested, [Uri.parse(_imageUrl)]); + final decoded = img.decodePng(png! as Uint8List)!; + expect(decoded.width, CoordinateSystem.screenShotSize.width); + expect(decoded.height, CoordinateSystem.screenShotSize.height); + expect(_magentaPixels(png as Uint8List), greaterThan(500)); + // The capture leaves the editor's coordinate mode as it found it. + expect(CoordinateSystem.instance.isScreenshot, isFalse); + }); + + testWidgets('a capture never touches the app\'s cloud session', + (tester) async { + // A capture's own container builds a strategy provider, which listens + // to auth. A real auth provider there would set, and on dispose tear + // down, auth on the one Convex client the editor syncs through. + final convex = _RecordingConvexAuth(); + AuthProvider.debugConvexApi = convex; + AuthProvider.debugSupabaseApi = _NoSupabase(); + addTearDown(AuthProvider.resetTestOverrides); + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + + final png = await captureWithFrames( + tester, + () => http.runWithClient( + () => captureEditorPage(ref), + () => MockClient((_) async => http.Response.bytes(_magentaPng, 200)), + ), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + + expect(png, isA()); + expect(convex.calls, isEmpty); + }); + + testWidgets('an image whose URL has not arrived stops the capture', + (tester) async { + final ref = await openCloudPage(tester, imageUrl: null); + + final error = await captureWithFrames( + tester, + () => captureEditorPage(ref), + ); + + expect( + error, + isA() + .having((error) => error.cause, 'cause', isNull), + ); + }); + + testWidgets('an image that cannot be fetched stops the capture', + (tester) async { + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + + final error = await captureWithFrames( + tester, + () => http.runWithClient( + () => captureEditorPage(ref), + () => MockClient((_) async => http.Response('down', 500)), + ), + ); + + expect( + error, + isA() + .having((error) => error.cause, 'cause', isNotNull), + ); + }); + + testWidgets('bytes that are not an image stop the capture', (tester) async { + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + + final error = await captureWithFrames( + tester, + () => http.runWithClient( + () => captureEditorPage(ref), + () => MockClient( + (_) async => http.Response.bytes([1, 2, 3, 4], 200), + ), + ), + ); + + expect( + error, + isA() + .having((error) => error.cause, 'cause', isNotNull), + ); + }); + + testWidgets('edits made while a capture waits never reach its copy', + (tester) async { + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + final live = ref.read(placedImageProvider).images.single; + + final snapshot = editorPageSnapshot(ref); + live.scale = live.scale * 2; + + expect(snapshot.imageData.single, isNot(same(live))); + expect(snapshot.imageData.single.scale, live.scale / 2); + expect(snapshot.name, 'Retake B'); + }); + + testWidgets('a second click while the save dialog is open does nothing', + (tester) async { + final ref = await openCloudPage(tester, imageUrl: _imageUrl); + final picker = _PendingPicker(); + FilePicker.platform = picker; + final container = ProviderScope.containerOf( + tester.element(find.byType(Consumer)), + ); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const ShadApp(home: Scaffold(body: EditorToolbar())), + )); + expect(ref, isNotNull); + + Future clickCamera() => tester.runAsync( + () => http.runWithClient( + () => tester.tap(find.byIcon(LucideIcons.camera200)), + () => MockClient( + (_) async => http.Response.bytes(_magentaPng, 200), + ), + ), + ); + + await clickCamera(); + // Real time with the app's frames coming, until the dialog opens. + for (var i = 0; i < 400 && picker.saves.isEmpty; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 16)), + ); + await tester.pump(); + } + expect(picker.saves, hasLength(1)); + expect(picker.saves.single, isNotEmpty); + + // The spinner is gone while the dialog is open, but the button waits. + expect(find.byIcon(LucideIcons.camera200), findsOneWidget); + await clickCamera(); + for (var i = 0; i < 30; i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 16)), + ); + await tester.pump(); + } + expect(picker.saves, hasLength(1)); + expect(find.byIcon(LucideIcons.camera200), findsOneWidget); + + picker.closed.complete(null); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('a screenshot that fails while fetching clears the spinner', + (tester) async { + await openCloudPage(tester, imageUrl: _imageUrl); + final container = ProviderScope.containerOf( + tester.element(find.byType(Consumer)), + ); + appProviderContainer = container; + final response = Completer(); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const ShadApp(home: Scaffold(body: EditorToolbar())), + )); + await http.runWithClient( + () => tester.tap(find.byIcon(LucideIcons.camera200)), + () => MockClient((_) => response.future), + ); + await tester.pump(); + // The camera turns into a spinner while the image is fetched. + expect(find.byIcon(LucideIcons.camera200), findsNothing); + expect(CoordinateSystem.instance.isScreenshot, isFalse); + + response.complete(http.Response('down', 500)); + await tester.pump(); + expect(find.byIcon(LucideIcons.camera200), findsOneWidget); + expect(CoordinateSystem.instance.isScreenshot, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/paranoia_range_migration_test.dart b/test/paranoia_range_migration_test.dart index 6c33dbbc..b57ee8ac 100644 --- a/test/paranoia_range_migration_test.dart +++ b/test/paranoia_range_migration_test.dart @@ -10,7 +10,8 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/migrations/paranoia_range_migration.dart'; import 'package:icarus/providers/strategy_page.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; const _virtualToWorld = 1000 / 831; @@ -117,7 +118,7 @@ void main() { test('v103 Paranoias keep their Omen end on both sides and in lineups', () { final source = _strategy(); - final result = StrategyProvider.migrateToCurrentVersion(source); + final result = StrategyMigrator.migrateToCurrentVersion(source); expect(result.versionNumber, Settings.versionNumber); for (var i = 0; i < source.pages.length; i++) { final before = source.pages[i]; @@ -132,7 +133,7 @@ void main() { expect(after.agentData.single.toJson(), before.agentData.single.toJson()); } expect( - identical(StrategyProvider.migrateToCurrentVersion(result), result), + identical(StrategyMigrator.migrateToCurrentVersion(result), result), isTrue, ); }); @@ -147,14 +148,14 @@ void main() { ), ), ]); - final result = StrategyProvider.migrateToCurrentVersion(source); + final result = StrategyMigrator.migrateToCurrentVersion(source); expect(identical(result.pages, source.pages), isTrue); expect(result.lastEdited, source.lastEdited); expect(result.versionNumber, Settings.versionNumber); }); test('the far end moves out by 3 m', () { - final result = StrategyProvider.migrateToCurrentVersion(_strategy()); + final result = StrategyMigrator.migrateToCurrentVersion(_strategy()); final before = _strategy().pages.first.abilityData.first; final after = result.pages.first.abilityData.first; // Unrotated local frame: the Omen end is the bottom centre, the far end @@ -170,7 +171,7 @@ void main() { }); test('zip export/import does not shift Paranoia a second time', () async { - final migrated = await StrategyProvider.migrateLegacyData(_strategy()); + final migrated = await StrategyMigrator.migrateLegacyData(_strategy()); final exportedPages = migrated.pages.map((p) => p.toJson(migrated.id)).toList(); final bytes = utf8.encode( @@ -195,7 +196,7 @@ void main() { strategyID: migrated.id, isZip: true, ); - final restored = await StrategyProvider.migrateLegacyData( + final restored = await StrategyMigrator.migrateLegacyData( migrated.copyWith( versionNumber: int.parse(decoded['versionNumber'] as String), pages: pages, diff --git a/test/pending_share_code_store_test.dart b/test/pending_share_code_store_test.dart new file mode 100644 index 00000000..814276ce --- /dev/null +++ b/test/pending_share_code_store_test.dart @@ -0,0 +1,521 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/share/pending_share_code_store.dart'; +import 'package:icarus/share/share_link_format.dart'; +import 'package:toastification/toastification.dart'; + +const _code = 'ICR-2345-6789-ABCD-EFGH'; + +void main() { + group('MemoryPendingShareCodeStore', () { + test('stores, restores, and clears a code', () { + final store = MemoryPendingShareCodeStore(); + expect(store.read(), isNull); + + store.write(_code); + expect(store.read(), _code); + + store.write('ICR-XXXX-XXXX-XXXX-XXXX'); + expect(store.read(), 'ICR-XXXX-XXXX-XXXX-XXXX', reason: 'latest wins'); + + store.clear(); + expect(store.read(), isNull); + }); + + test('native builds keep the code in memory', () { + expect(createPendingShareCodeStore(), isA()); + }); + }); + + group('ShareLinkController pending code', () { + ProviderContainer containerWith( + PendingShareCodeStore store, { + required bool signedIn, + ConvexStrategyRepository? repository, + }) { + final container = ProviderContainer( + overrides: [ + pendingShareCodeStoreProvider.overrideWithValue(store), + authProvider.overrideWith( + () => _FixedAuthProvider(signedIn: signedIn), + ), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + return container; + } + + test('a code viewed while signed out survives into the next page load', + () async { + // Shared by both containers, like sessionStorage across a Discord + // round trip. + final store = MemoryPendingShareCodeStore(); + + final beforeSignIn = containerWith( + store, + signedIn: false, + repository: _LinkRepository(resolvesTo: 'strategy-1'), + ); + await beforeSignIn + .read(shareLinkControllerProvider.notifier) + .redeemToken(_code); + expect(store.read(), _code, reason: 'signing in still redeems it'); + + final afterSignIn = containerWith(store, signedIn: false); + expect(afterSignIn.read(shareLinkControllerProvider), _code); + }); + + test('a late failure of an older attempt keeps the newer pending code', + () async { + const newer = 'ICR-XXXX-XXXX-XXXX-XXXX'; + final store = MemoryPendingShareCodeStore(); + final repository = _ControlledRepository(); + final container = containerWith( + store, + signedIn: true, + repository: repository, + ); + final controller = container.read(shareLinkControllerProvider.notifier); + + // Attempt A is in flight when code B becomes pending. + final attemptA = controller.redeemToken(_code); + final attemptB = controller.redeemToken(newer); + expect(container.read(shareLinkControllerProvider), newer); + + repository.fail(_code); + expect(await attemptA, isFalse); + expect(container.read(shareLinkControllerProvider), newer); + expect(store.read(), newer); + + // B's own failure is the one that clears B. + repository.fail(newer); + expect(await attemptB, isFalse); + expect(container.read(shareLinkControllerProvider), isNull); + expect(store.read(), isNull); + }); + + test('a failed redemption the user was told about clears the code', + () async { + final store = MemoryPendingShareCodeStore()..write(_code); + final container = containerWith( + store, + signedIn: true, + repository: _FailingRepository(), + ); + + expect(container.read(shareLinkControllerProvider), _code); + final redeemed = await container + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(showFailureToasts: false); + + expect(redeemed, isFalse); + expect(container.read(shareLinkControllerProvider), isNull); + expect(store.read(), isNull); + }); + }); + + group('opening a share link', () { + final link = icarusProductionShareOrigin.replace(path: '/share/$_code'); + // The page as a web build sees it after opening the link; the router's + // fragment rides along. + final pageUrl = link.replace(fragment: '/strategy_view'); + late List replacedUrls; + + setUp(() => replacedUrls = []); + + Future pumpApp( + WidgetTester tester, { + required AppAuthState auth, + PendingShareCodeStore? store, + ConvexStrategyRepository? repository, + }) async { + final container = ProviderContainer(overrides: [ + pendingShareCodeStoreProvider + .overrideWithValue(store ?? MemoryPendingShareCodeStore()), + authProvider.overrideWith(() => _SettableAuthProvider(auth)), + sharePageUrlProvider.overrideWithValue(pageUrl), + replaceBrowserUrlProvider.overrideWithValue(replacedUrls.add), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + ]); + addTearDown(container.dispose); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const ToastificationWrapper( + child: ShadApp(home: Scaffold()), + ), + )); + return container; + } + + Future settleToasts(WidgetTester tester) async { + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + } + + // toastification is a global: clear it and let every auto-close timer + // run out, so no toast outlives its test or lands in the next one. + Future clearToasts(WidgetTester tester) async { + toastification.dismissAll(delayForAnimation: false); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + } + + testWidgets('while a saved session is still restoring, it waits quietly', + (tester) async { + final store = MemoryPendingShareCodeStore(); + // Already accessible, so redeeming shows no toast of its own. + final repository = _RedeemingRepository(const ShareRedemption( + targetType: 'strategy', + strategyPublicId: 'strategy-1', + role: 'viewer', + alreadyHadAccess: true, + )); + final container = await pumpApp( + tester, + auth: _auth(session: true, cloudReady: false), + store: store, + repository: repository, + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect(container.read(shareLinkViewProvider), isNull, + reason: 'a session is coming; it will redeem the link'); + expect(replacedUrls, isEmpty, reason: 'the code is still waiting'); + expect(store.read(), _code, reason: 'held until the cloud is ready'); + expect(repository.redeemed, isEmpty); + + // The app redeems it once the cloud is ready (main.dart's listener). + (container.read(authProvider.notifier) as _SettableAuthProvider) + .set(_auth(session: true, cloudReady: true)); + await container + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(); + expect(repository.redeemed, [_code]); + expect( + replacedUrls, + [ + icarusProductionShareOrigin.replace( + path: '/', fragment: '/strategy_view') + ], + reason: 'a reload must not redeem it again'); + expect(store.read(), isNull); + await clearToasts(tester); + }); + + testWidgets('while signed out, it opens the strategy read-only', + (tester) async { + final store = MemoryPendingShareCodeStore(); + final repository = _LinkRepository(resolvesTo: 'strategy-1'); + final container = await pumpApp( + tester, + auth: _auth(session: false, cloudReady: false), + store: store, + repository: repository, + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect( + container.read(shareLinkViewProvider), + (strategyPublicId: 'strategy-1', token: _code), + ); + expect(container.read(sharedStrategyToOpenProvider), 'strategy-1'); + expect(store.read(), _code, reason: 'kept for after sign-in'); + expect(replacedUrls, isEmpty, reason: 'a reload opens it again'); + expect(repository.redeemed, isEmpty); + + // Another auth update asks again; the open view is not reopened. + container.read(sharedStrategyToOpenProvider.notifier).state = null; + await container + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(); + expect(container.read(sharedStrategyToOpenProvider), isNull); + + // Signing in redeems the link, and the strategy opens as the + // reader's own. + (container.read(authProvider.notifier) as _SettableAuthProvider) + .set(_auth(session: true, cloudReady: true)); + await container + .read(shareLinkControllerProvider.notifier) + .redeemPendingIfPossible(); + await settleToasts(tester); + expect(repository.redeemed, [_code]); + expect(container.read(shareLinkViewProvider), isNull); + expect(container.read(sharedStrategyToOpenProvider), 'strategy-1'); + expect(store.read(), isNull); + await clearToasts(tester); + }); + + testWidgets( + 'a lookup that lands after sign-in redeemed the link is dropped', + (tester) async { + final repository = _LinkRepository(resolvesTo: 'strategy-1') + ..holdResolve = Completer(); + final container = await pumpApp( + tester, + auth: _auth(session: false, cloudReady: false), + repository: repository, + ); + final controller = container.read(shareLinkControllerProvider.notifier); + + // The signed-out lookup is still on the wire when sign-in finishes. + final lookup = controller.handleIncomingUri(link, source: 'test'); + (container.read(authProvider.notifier) as _SettableAuthProvider) + .set(_auth(session: true, cloudReady: true)); + await controller.redeemPendingIfPossible(); + expect(repository.redeemed, [_code]); + + repository.holdResolve!.complete(); + await lookup; + await settleToasts(tester); + expect(container.read(shareLinkViewProvider), isNull, + reason: "the redeemed strategy is now the reader's own"); + await clearToasts(tester); + }); + + testWidgets('while signed out, a folder link asks the user to sign in', + (tester) async { + final store = MemoryPendingShareCodeStore(); + final container = await pumpApp( + tester, + auth: _auth(session: false, cloudReady: false), + store: store, + repository: _LinkRepository(resolvesTo: null), + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect(toast('Sign in to open this shared folder.'), findsOneWidget); + expect(container.read(shareLinkViewProvider), isNull); + expect(store.read(), _code, reason: 'kept for after sign-in'); + expect(replacedUrls, isEmpty); + await clearToasts(tester); + }); + + testWidgets('while signed out, a disabled link says so and is dropped', + (tester) async { + final store = MemoryPendingShareCodeStore(); + final container = await pumpApp( + tester, + auth: _auth(session: false, cloudReady: false), + store: store, + repository: _LinkRepository( + resolveError: const ConvexFunctionException( + code: ConvexErrorCode.shareLinkRevoked, + rawCode: 'SHARE_LINK_REVOKED', + message: 'Share link revoked', + ), + ), + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect( + toast('This share link was disabled by its owner.'), + findsOneWidget, + ); + expect(container.read(shareLinkViewProvider), isNull); + expect(container.read(sharedStrategyToOpenProvider), isNull); + expect(store.read(), isNull); + expect(replacedUrls, hasLength(1), reason: 'a reload must not retry it'); + await clearToasts(tester); + }); + + testWidgets( + 'the owner of the strategy is taken to it, with no "added" toast', + (tester) async { + final container = await pumpApp( + tester, + auth: _auth(session: true, cloudReady: true), + repository: _RedeemingRepository(const ShareRedemption( + targetType: 'strategy', + strategyPublicId: 'strategy-1', + folderPublicId: 'folder-1', + role: 'owner', + alreadyHadAccess: true, + )), + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect(toast('Shared strategy added to your library.'), findsNothing); + expect( + container.read(cloudLibrarySectionProvider), + CloudLibrarySection.home, + ); + expect(container.read(sharedStrategyToOpenProvider), 'strategy-1'); + expect(replacedUrls, [ + icarusProductionShareOrigin.replace( + path: '/', fragment: '/strategy_view') + ]); + await clearToasts(tester); + }); + + testWidgets('a newly shared strategy is added to Shared, then opened', + (tester) async { + final container = await pumpApp( + tester, + auth: _auth(session: true, cloudReady: true), + repository: _RedeemingRepository(const ShareRedemption( + targetType: 'strategy', + strategyPublicId: 'strategy-2', + role: 'viewer', + )), + ); + + await container + .read(shareLinkControllerProvider.notifier) + .handleIncomingUri(link, source: 'test'); + await settleToasts(tester); + + expect( + toast('Shared strategy added to your library.'), + findsOneWidget, + ); + expect( + container.read(cloudLibrarySectionProvider), + CloudLibrarySection.sharedWithMe, + ); + expect(container.read(sharedStrategyToOpenProvider), 'strategy-2'); + await clearToasts(tester); + }); + }); +} + +/// A toast's text. Toasts sit in an overlay entry that is still animating +/// in when these tests look, so offstage widgets count. +Finder toast(String text) => find.byWidgetPredicate( + (widget) => widget is Text && widget.data == text, + skipOffstage: false, + ); + +AppAuthState _auth({required bool session, required bool cloudReady}) { + return AppAuthState( + isLoading: false, + isAuthenticated: session, + isConvexUserReady: session && cloudReady, + convexAuthStatus: !session + ? ConvexAuthStatus.signedOut + : cloudReady + ? ConvexAuthStatus.ready + : ConvexAuthStatus.configuring, + user: null, + ); +} + +class _SettableAuthProvider extends AuthProvider { + _SettableAuthProvider(this._initial); + + final AppAuthState _initial; + + @override + AppAuthState build() => _initial; + + void set(AppAuthState next) => state = next; +} + +class _RedeemingRepository extends Fake implements ConvexStrategyRepository { + _RedeemingRepository(this.response); + + final ShareRedemption response; + final List redeemed = []; + + @override + Future redeemShareLink(String token) async { + redeemed.add(token); + return response; + } +} + +/// Answers what a link shares ([resolvesTo] null for a folder), and redeems. +class _LinkRepository extends Fake implements ConvexStrategyRepository { + _LinkRepository({this.resolvesTo, this.resolveError}); + + final String? resolvesTo; + final Object? resolveError; + final List redeemed = []; + + /// When set, lookups wait for it to complete. + Completer? holdResolve; + + @override + Future resolveSharedStrategy(String token) async { + await holdResolve?.future; + if (resolveError case final error?) throw error; + return resolvesTo; + } + + @override + Future redeemShareLink(String token) async { + redeemed.add(token); + return ShareRedemption( + targetType: 'strategy', + strategyPublicId: resolvesTo, + role: 'viewer', + ); + } +} + +class _FixedAuthProvider extends AuthProvider { + _FixedAuthProvider({required this.signedIn}); + + final bool signedIn; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: signedIn, + isConvexUserReady: signedIn, + convexAuthStatus: + signedIn ? ConvexAuthStatus.ready : ConvexAuthStatus.signedOut, + user: null, + ); +} + +/// Holds each redemption open until the test fails it. +class _ControlledRepository extends Fake implements ConvexStrategyRepository { + final _pending = >{}; + + void fail(String token) => + _pending[token]!.completeError(Exception('Share link revoked')); + + @override + Future redeemShareLink(String token) => + (_pending[token] = Completer()).future; +} + +class _FailingRepository extends Fake implements ConvexStrategyRepository { + @override + Future redeemShareLink(String token) async { + throw Exception('Share link revoked'); + } +} diff --git a/test/per_object_undo_test.dart b/test/per_object_undo_test.dart new file mode 100644 index 00000000..54033df2 --- /dev/null +++ b/test/per_object_undo_test.dart @@ -0,0 +1,231 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +class _NoopStrategyProvider extends StrategyProvider { + @override + StrategyState build() { + return const StrategyState( + strategyId: 'test-strategy', + strategyName: null, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + } + + @override + void setUnsaved() {} +} + +ProviderContainer _container() { + final container = ProviderContainer( + overrides: [strategyProvider.overrideWith(_NoopStrategyProvider.new)], + ); + addTearDown(container.dispose); + return container; +} + +PlacedAgent _agent(String id, Offset position) => + PlacedAgent(id: id, type: AgentType.jett, position: position); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + test('undoing a clear puts objects back in their stacking order', () { + final container = _container(); + container.read(agentProvider.notifier).fromHive([ + _agent('bottom', const Offset(10, 10)), + _agent('middle', const Offset(20, 20)), + _agent('top', const Offset(30, 30)), + ]); + final history = container.read(actionProvider.notifier); + + history.clearGroupAsAction(ActionGroup.agent); + history.undoAction(); + + expect( + container.read(agentProvider).map((agent) => agent.id), + ['bottom', 'middle', 'top'], + ); + }); + + test('a utility elevation change undoes after a later move is undone', () { + final container = _container(); + final utilities = container.read(utilityProvider.notifier) + ..fromHive([ + PlacedUtility( + id: 'cone', + type: UtilityType.viewCone90, + position: const Offset(40, 40), + ), + ]); + final history = container.read(actionProvider.notifier); + + utilities.updateViewConeElevation('cone', 150); + utilities.updatePosition(const Offset(90, 90), 'cone'); + history.undoAction(); + history.undoAction(); + + final cone = container.read(utilityProvider).single; + expect(cone.position, const Offset(40, 40)); + expect(cone.visionElevation, isNull); + + history.redoAction(); + expect(container.read(utilityProvider).single.visionElevation, 150); + }); + + test('deleting a link into a shared landing undoes and redoes cleanly', () { + final container = _container(); + final lineUps = container.read(lineUpProvider.notifier) + ..fromHive( + LineUpGraph( + origins: [ + LineUpOrigin(id: 'o1', agent: _agent('a1', const Offset(10, 10))), + LineUpOrigin(id: 'o2', agent: _agent('a2', const Offset(90, 10))), + ], + landings: [ + LineUpLanding( + id: 'shared', + ability: PlacedAbility( + id: 'ability', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(50, 80), + ), + ), + ], + links: [ + LineUpLink(id: 'k1', originId: 'o1', landingId: 'shared'), + LineUpLink(id: 'k2', originId: 'o2', landingId: 'shared'), + ], + ), + ); + final history = container.read(actionProvider.notifier); + List ids(Iterable entries) => [ + for (final entry in entries) + switch (entry) { + LineUpOrigin(:final id) => id, + LineUpLanding(:final id) => id, + LineUpLink(:final id) => id, + _ => '?', + }, + ]; + void expectGraph(List origins, List links) { + final graph = container.read(lineUpProvider); + expect(ids(graph.origins), unorderedEquals(origins)); + expect(ids(graph.landings), ['shared']); + expect(ids(graph.links), unorderedEquals(links)); + } + + lineUps.deleteLink('k1'); + expectGraph(['o2'], ['k2']); + + history.undoAction(); + expectGraph(['o1', 'o2'], ['k1', 'k2']); + expect(container.read(lineUpProvider).linkById('k1')!.landingId, 'shared'); + + history.redoAction(); + expectGraph(['o2'], ['k2']); + + history.undoAction(); + expectGraph(['o1', 'o2'], ['k1', 'k2']); + expect(history.poppedItems, hasLength(1)); + }); + + test('one undo steps past entries that change nothing to one that does', () { + final container = _container(); + final agents = container.read(agentProvider.notifier) + ..fromHive([ + _agent('sova', const Offset(10, 10)), + _agent('jett', const Offset(20, 20)), + ]); + final history = container.read(actionProvider.notifier); + + agents.updatePosition(const Offset(100, 100), 'sova'); + agents.updatePosition(const Offset(200, 200), 'jett'); + // Jett goes without a history entry, as a teammate's deletion does + // before history is reconciled. + agents.removeAgent('jett'); + + history.undoAction(); + expect(container.read(agentProvider).single.position, const Offset(10, 10)); + expect(container.read(actionProvider), isEmpty); + + history.redoAction(); + expect( + container.read(agentProvider).single.position, const Offset(100, 100)); + }); + + test('alternating clears keep history growing linearly', () { + final container = _container(); + final history = container.read(actionProvider.notifier); + int retained(Iterable actions) => actions.fold( + 0, + (count, action) => count + 1 + retained(action.changes), + ); + + for (var i = 0; i < 20; i++) { + container + .read(agentProvider.notifier) + .addAgent(_agent('agent-$i', const Offset(10, 10))); + container.read(utilityProvider.notifier).addUtility(PlacedUtility( + id: 'utility-$i', + type: UtilityType.viewCone90, + position: const Offset(40, 40), + )); + history.clearGroupAsAction( + i.isEven ? ActionGroup.agent : ActionGroup.utility, + ); + } + + // Each round adds two objects and one clear of at most two of them. + expect( + retained([...container.read(actionProvider), ...history.poppedItems]), + lessThanOrEqualTo(20 * 5), + ); + }); + + test('history keeps the most recent steps up to its limit', () { + final container = _container(); + final agents = container.read(agentProvider.notifier) + ..fromHive([_agent('jett', const Offset(0, 0))]); + + for (var i = 1; i <= ActionProvider.historyLimit + 10; i++) { + agents.updatePosition(Offset(i.toDouble(), 0), 'jett'); + } + + final steps = container.read(actionProvider); + expect(steps, hasLength(ActionProvider.historyLimit)); + // The oldest ten went; the first kept step moved Jett from x = 10. + expect( + steps.first.objectDelta!.before!.agent!.position, const Offset(10, 0)); + }); + + test('a transaction that changes nothing adds no undo step', () { + final container = _container(); + container + .read(agentProvider.notifier) + .fromHive([_agent('jett', const Offset(10, 10))]); + + container.read(actionProvider.notifier).performTransaction( + groups: const [ActionGroup.agent], + // Not a view cone agent, so there is nothing to convert. + mutation: () => container + .read(agentProvider.notifier) + .convertViewConeAgentToPlain(id: 'jett'), + ); + + expect(container.read(actionProvider), isEmpty); + }); +} diff --git a/test/placed_box_resize_test.dart b/test/placed_box_resize_test.dart index 0f2f45a3..02bedea6 100644 --- a/test/placed_box_resize_test.dart +++ b/test/placed_box_resize_test.dart @@ -1,13 +1,9 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/placed_classes.dart'; -import 'package:icarus/providers/color_library_provider.dart'; import 'package:icarus/providers/image_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/widgets/draggable_widgets/image/placed_image_builder.dart'; import 'package:icarus/widgets/draggable_widgets/image/scalable_widget.dart'; @@ -15,16 +11,6 @@ import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_scale_controller.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; -class _FixedStorageStrategyProvider extends StrategyProvider { - @override - StrategyState build() => StrategyState( - isSaved: false, - stratName: null, - id: 'placed-box-resize-test', - storageDirectory: Directory.systemTemp.path, - ); -} - void main() { const playArea = Size(1600, 900); const originalPosition = Offset(500, 400); @@ -40,18 +26,7 @@ void main() { addTearDown(tester.view.reset); CoordinateSystem(playAreaSize: playArea); - // Images read the strategy's storage folder and, for their tag menu, the - // color library, which lives in Hive. - final container = ProviderContainer( - overrides: [ - strategyProvider.overrideWith(_FixedStorageStrategyProvider.new), - colorLibraryProvider.overrideWith( - (ref) => const [ - ColorLibraryEntry(color: Colors.white, isCustom: false), - ], - ), - ], - ); + final container = ProviderContainer(); addTearDown(container.dispose); seed(container); diff --git a/test/placed_media_dimensions_test.dart b/test/placed_media_dimensions_test.dart new file mode 100644 index 00000000..cc9b482a --- /dev/null +++ b/test/placed_media_dimensions_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + test('image helper returns expected width and height', () { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 2.0, + ); + + final expectedWidth = CoordinateSystem.instance + .worldWidthToScreen(ImageScalePolicy.defaultWidth); + final cardWidth = expectedWidth - + PlacedImageDimensions.tagWidth - + PlacedImageDimensions.tagGap; + final contentWidth = cardWidth - (PlacedImageDimensions.imagePadding * 2); + final expectedHeight = + (contentWidth / 2.0) + (PlacedImageDimensions.imagePadding * 2); + + expect(size.width, expectedWidth); + expect(size.height, expectedHeight); + }); + + test('image helper clamps scale', () { + final size = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.maxWidth * 10, + aspectRatio: 1.0, + ); + + expect( + size.width, + CoordinateSystem.instance.worldWidthToScreen(ImageScalePolicy.maxWidth), + ); + }); + + test('image helper falls back to square aspect ratio', () { + final zeroAspect = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 0, + ); + final squareAspect = PlacedImageDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + scale: ImageScalePolicy.defaultWidth, + aspectRatio: 1, + ); + + expect(zeroAspect, squareAspect); + }); + + test('text helper returns deterministic screen width', () { + final size = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'one line', + ); + + expect(size.width, CoordinateSystem.instance.worldWidthToScreen(220)); + }); + + test('text helper uses one-line height for empty text', () { + final empty = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: '', + ); + final singleLine = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'one line', + ); + + expect(empty.height, singleLine.height); + expect(empty.height, lessThan(64)); + }); + + test('text helper height increases for wrapped text', () { + final singleLine = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 220, + fontSizeWorld: 16, + text: 'short text', + ); + final wrapped = PlacedTextDimensions.screenSize( + coordinateSystem: CoordinateSystem.instance, + widthWorld: 80, + fontSizeWorld: 16, + text: 'this is a long annotation that should wrap across several lines', + ); + + expect(wrapped.height, greaterThan(singleLine.height)); + }); +} diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart new file mode 100644 index 00000000..d1d0ba23 --- /dev/null +++ b/test/providers/auth_provider_test.dart @@ -0,0 +1,999 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/const/app_navigator.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/in_app_debug_provider.dart'; +import 'package:icarus/services/app_error_reporter.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + appProviderContainer = ProviderContainer(); + }); + + tearDownAll(() { + appProviderContainer.dispose(); + }); + + late FakeSupabaseApi supabaseApi; + late FakeConvexApi convexApi; + + setUp(() { + supabaseApi = FakeSupabaseApi(); + convexApi = FakeConvexApi(); + appProviderContainer.read(inAppDebugProvider.notifier).clearLogs(); + AuthProvider.debugSupabaseApi = supabaseApi; + AuthProvider.debugConvexApi = convexApi; + AuthProvider.debugConvexAuthReadyTimeout = const Duration(milliseconds: 50); + }); + + tearDown(() async { + AuthProvider.resetTestOverrides(); + await supabaseApi.dispose(); + }); + + test('build with existing Supabase session does not throw', () async { + supabaseApi.currentSession = fakeSession(); + supabaseApi.emitInitialSessionOnListen = true; + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(() => container.read(authProvider), returnsNormally); + + final state = container.read(authProvider); + expect(state.isAuthenticated, isTrue); + await pumpMicrotasks(); + }); + + test( + 'a restored session gets one Convex setup, not one from build and ' + 'another from initialSession', () async { + // A page refresh on web: Supabase restores the session before the + // provider builds, then replays it to the new listener as initialSession, + // and the Convex socket takes a while to confirm the token. + supabaseApi.currentSession = fakeSession(); + supabaseApi.emitInitialSessionOnListen = true; + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + convexApi.emitAuthState(true); + await pumpMicrotasks(); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + expect(state.isConvexUserReady, isTrue); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.mutationCalls, 1); + expect(convexApi.handles.single.isDisposed, isFalse); + }); + + test('initialSession with a different session than build still sets up', + () async { + supabaseApi.currentSession = fakeSession(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + + // Supabase refreshed the restored session before replaying it. + final refreshed = fakeSession(accessToken: 'refreshed-token'); + supabaseApi.currentSession = refreshed; + supabaseApi.emit(AuthChangeEvent.initialSession, refreshed); + await pumpMicrotasks(); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 2); + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.ready, + ); + }); + + test('a Convex setup failure is reported, redacted, to the console', + () async { + final printed = []; + final originalDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) printed.add(message); + }; + AppErrorReporter.echoToConsole = true; + addTearDown(() { + debugPrint = originalDebugPrint; + AppErrorReporter.echoToConsole = false; + }); + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = StateError('boom access_token=secret-value'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.incident, + ); + final entry = appProviderContainer + .read(inAppDebugProvider) + .singleWhere((entry) => entry.source == 'auth'); + expect(entry.message, 'Convex auth setup failed [build]'); + expect(entry.errorText, 'Bad state: boom access_token='); + expect(printed, [ + '[auth] warning: Convex auth setup failed [build]: ' + 'Bad state: boom access_token=', + ]); + final everything = [ + entry.message, + entry.errorText, + entry.stackTrace, + ...printed, + ].join('\n'); + expect(everything, isNot(contains('secret-value'))); + }); + + test('build queues Convex auth setup without gating auth events', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + final initialState = container.read(authProvider); + + expect(initialState.isAuthenticated, isTrue); + expect(initialState.convexAuthStatus, ConvexAuthStatus.configuring); + expect(convexApi.setAuthCalls, 0); + + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.setAuthCalls, 1); + expect(state.convexAuthStatus, ConvexAuthStatus.configuring); + expect(state.activeAuthIncidentId, isNull); + expect(convexApi.reconnectCalls, 0); + + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + }); + + test('startup auth calls reconnect before waiting for readiness', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + }); + + test( + 'does not call ensureCurrentUser before Convex auth becomes authenticated', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect(convexApi.lastMutationName, 'users:ensureCurrentUser'); + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + }); + + test( + 'callback login relies on auth-state listener and does not schedule duplicate setup', + () async { + supabaseApi.sessionFromUrlSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + final handled = + await container.read(authProvider.notifier).handleAuthCallbackUri( + Uri.parse('icarus://auth/callback?code=test-code'), + source: 'test', + ); + await pumpMicrotasks(); + + expect(handled, isTrue); + expect(supabaseApi.getSessionFromUrlCalls, 1); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.isAuthenticated, isTrue); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + }); + + test('a link carrying session tokens is rejected without a sign-in', + () async { + // Anyone can craft this link. GoTrue would import the tokens and sign + // the user into the attacker's account, so it must never reach it. + final injected = Uri.parse( + 'icarus://auth/callback#access_token=attacker-access' + '&refresh_token=attacker-refresh&expires_in=3600&token_type=bearer', + ); + supabaseApi.sessionFromUrlSession = fakeSession(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + final before = container.read(authProvider); + + final handled = await container + .read(authProvider.notifier) + .handleAuthCallbackUri(injected, source: 'test'); + await pumpMicrotasks(); + + expect(handled, isTrue, reason: 'handled, so the caller scrubs the URL'); + expect(supabaseApi.getSessionFromUrlCalls, 0); + expect(supabaseApi.currentSession, isNull); + final after = container.read(authProvider); + expect(after.isAuthenticated, before.isAuthenticated); + expect(after.convexAuthStatus, before.convexAuthStatus); + expect(after.isLoading, isFalse); + expect(convexApi.setAuthCalls, 0); + + final report = AppErrorReporter.buildClipboardReport( + appProviderContainer.read(inAppDebugProvider), + ); + expect(report, contains('Rejected auth callback carrying session tokens')); + expect(report, isNot(contains('attacker-access'))); + expect(report, isNot(contains('attacker-refresh'))); + }); + + group('redactDeepLinkUri', () { + const code = 'ICR-2345-6789-ABCD-EFGH'; + + test('hides share codes in every link form', () { + for (final link in [ + 'https://beta.icarusstrats.com/share/$code', + 'https://icarusstrats.com/share/$code?utm=x', + 'https://icarusstrats.com/share?code=$code', + 'https://icarusstrats.com/share?token=0ee927ca-babc-4350', + 'icarus://share?code=$code', + 'icarus://share/$code', + ]) { + final redacted = redactDeepLinkUri(Uri.parse(link)); + expect(redacted, contains('redacted'), reason: link); + expect(redacted, isNot(contains(code)), reason: link); + expect(redacted, isNot(contains('0ee927ca')), reason: link); + } + expect( + redactDeepLinkUri( + Uri.parse('https://icarusstrats.com/share/$code?utm=x'), + ), + 'https://icarusstrats.com/share/%3Credacted%3E?utm=x', + ); + }); + + test('hides sign-in secrets in the query and fragment', () { + final redacted = redactDeepLinkUri( + Uri.parse( + 'https://beta.icarusstrats.com/?code=pkce-secret' + '#access_token=access-secret&refresh_token=refresh-secret', + ), + ); + for (final secret in ['pkce-secret', 'access-secret', 'refresh-secret']) { + expect(redacted, isNot(contains(secret))); + } + }); + + test('leaves ordinary links readable and never throws', () { + expect( + redactDeepLinkUri(Uri.parse('https://beta.icarusstrats.com/#/')), + 'https://beta.icarusstrats.com/#/', + ); + expect( + redactDeepLinkUri( + Uri.parse('https://beta.icarusstrats.com/?x=%E0%A4%A#a=%E0%A4%A'), + ), + isNot(contains('%E0%A4')), + ); + }); + + test('a path that does not decode is fully redacted, not thrown', () { + const input = 'icarus://share/%FF?token=secret'; + expect( + () => redactDeepLinkUri(Uri.parse(input)), + returnsNormally, + ); + expect( + redactDeepLinkUri(Uri.parse(input)), + unparseableLinkPlaceholder, + ); + expect(redactLaunchArgument(input), unparseableLinkPlaceholder); + expect( + redactDeepLinkUri(Uri.parse('https://beta.icarusstrats.com/share/%FF')), + unparseableLinkPlaceholder, + ); + }); + + test('an unparseable link-shaped argument is not logged raw', () { + for (final argument in [ + 'https://[beta.icarusstrats.com/share/$code', + 'icarus://[share?code=$code', + ]) { + expect(Uri.tryParse(argument), isNull, reason: argument); + expect( + redactLaunchArgument(argument), + unparseableLinkPlaceholder, + reason: argument, + ); + } + expect( + redactLaunchArgument('icarus:share?code=$code'), + isNot(contains(code)), + reason: 'a parseable icarus: link without // is still redacted', + ); + }); + + test('launch arguments: links are redacted, file paths are not', () { + expect( + redactLaunchArgument('icarus://share?code=$code'), + isNot(contains(code)), + ); + expect( + redactLaunchArgument(r'C:\Users\me\plans\retake.ica'), + r'C:\Users\me\plans\retake.ica', + ); + }); + }); + + test('callback failure removes credentials from UI and diagnostics', + () async { + final callback = Uri.parse( + 'icarus://auth/callback?code=callback-code-secret', + ); + supabaseApi.sessionFromUrlError = Exception( + 'Provider rejected $callback ' + 'access_token=access-secret&refresh_token=refresh-secret ' + '{"code_verifier":"verifier-secret"} ' + 'access_token%3Dencoded-secret%26token_type%3Dbearer', + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + final handled = + await container.read(authProvider.notifier).handleAuthCallbackUri( + callback, + source: 'test', + ); + + expect(handled, isTrue); + expect( + container.read(authProvider).errorMessage, + 'Failed to complete login. Please try again.', + ); + + final report = AppErrorReporter.buildClipboardReport( + appProviderContainer.read(inAppDebugProvider), + ); + expect(report, contains('Failed auth callback [test]')); + expect(report, contains('redacted')); + for (final credential in [ + 'callback-code-secret', + 'access-secret', + 'refresh-secret', + 'verifier-secret', + 'encoded-secret', + ]) { + expect(report, isNot(contains(credential))); + } + }); + + test( + 'existing session on startup waits for Convex auth readiness before ensuring user', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.ready); + expect(state.isConvexUserReady, isTrue); + }); + + test('reconnect false still waits for authState and can recover', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.reconnectResult = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect( + container.read(authProvider).convexAuthStatus, ConvexAuthStatus.ready); + }); + + test( + 'email password sign-in relies on auth-state listener and does not duplicate setup', + () async { + supabaseApi.currentSession = fakeSession(); + supabaseApi.emitSignedInEventOnPasswordSignIn = true; + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + + final error = + await container.read(authProvider.notifier).signInWithEmailPassword( + email: 'test@example.com', + password: 'password', + ); + await pumpMicrotasks(); + + expect(error, isNull); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.reconnectCalls, 1); + expect(convexApi.mutationCalls, 0); + + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 1); + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.ready, + ); + }); + + test('stale startup setup does not mark user ready after sign-out', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + expect(convexApi.clearAuthCalls, 0); + + await container.read(authProvider.notifier).signOut(); + expect(container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.signedOut); + expect(convexApi.clearAuthCalls, 1); + + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + await pumpMicrotasks(); + convexApi.emitAuthState(true); + await pumpMicrotasks(); + + expect(convexApi.mutationCalls, 0); + final state = container.read(authProvider); + expect(state.isAuthenticated, isFalse); + expect(state.isConvexUserReady, isFalse); + expect(state.errorMessage, isNull); + expect(state.convexAuthStatus, ConvexAuthStatus.signedOut); + }); + + test('a signed-out startup leaves Convex alone', () async { + // Clearing auth that was never set would open the Convex socket for a + // user who never asked for the cloud. + supabaseApi.currentSession = null; + final container = ProviderContainer(); + addTearDown(container.dispose); + + final initialState = container.read(authProvider); + expect(initialState.convexAuthStatus, ConvexAuthStatus.signedOut); + + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.clearAuthCalls, 0); + expect(convexApi.setAuthCalls, 0); + expect(state.isAuthenticated, isFalse); + expect(state.convexAuthStatus, ConvexAuthStatus.signedOut); + }); + + test('a session that ends clears the Convex auth it set', () async { + supabaseApi.currentSession = fakeSession(); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + expect(convexApi.clearAuthCalls, 0); + + // The session expires or is revoked: Supabase reports it gone without + // a sign-out through the app. + supabaseApi.currentSession = null; + supabaseApi.emit(AuthChangeEvent.signedOut, null); + await pumpMicrotasks(); + + expect(convexApi.clearAuthCalls, 1); + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.signedOut, + ); + }); + + test('real unauthenticated error still creates auth incident', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNotNull); + expect( + state.errorMessage, + 'Your cloud session expired. Reconnect to resume syncing, or sign out.', + ); + }); + + testWidgets('auth incident Sign Out uses the guarded flow', (tester) async { + supabaseApi.currentSession = fakeSession(); + var guardedRequests = 0; + final container = ProviderContainer(overrides: [ + guardedSignOutRequestProvider.overrideWithValue((context) async { + guardedRequests += 1; + return false; + }), + ]); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: SizedBox.shrink()), + ), + ), + ); + final notifier = container.read(authProvider.notifier); + await tester.pump(); + await tester.pump(); + + await notifier.reportConvexUnauthenticated( + source: 'test:incident-route', + error: const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('Cloud connection lost'), findsOneWidget); + + await tester.tap(find.text('Sign Out')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(guardedRequests, 1); + expect(supabaseApi.currentSession, isNotNull); + }); + + test('auth readiness timeout surfaces as setup incident, not unauthenticated', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await Future.delayed(const Duration(milliseconds: 80)); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect( + state.errorMessage, + "Couldn't connect to cloud sync. Please retry.", + ); + expect(convexApi.mutationCalls, 0); + }); + + test( + 'non-auth setup error does not incorrectly become unauthenticated incident', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = StateError('boom'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect(state.errorMessage, "Couldn't connect to cloud sync. Please retry."); + }); + + test( + 'reconnect throwing still surfaces setup incident if readiness never arrives', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.autoAuthenticateOnSetAuth = false; + convexApi.reconnectError = StateError('reconnect failed'); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await Future.delayed(const Duration(milliseconds: 80)); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(convexApi.reconnectCalls, 1); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.activeAuthIncidentId, isNull); + expect( + state.errorMessage, + "Couldn't connect to cloud sync. Please retry.", + ); + }); + + test('a setup that ends after its provider is gone touches nothing', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + container.read(authProvider); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + + // The provider goes while setup waits on Convex, which then refuses it. + container.dispose(); + convexApi.mutationError = _upgradeRequired; + convexApi.emitAuthState(true); + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + // Readiness times out after 50 ms; the setup fails after that. Any read + // of a provider from the disposed container fails this test. + await Future.delayed(const Duration(milliseconds: 120)); + await pumpMicrotasks(); + }); + + test('a setup queued behind one running when the provider goes never runs', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.setAuthCompleter = Completer(); + final container = ProviderContainer(); + final notifier = container.read(authProvider.notifier); + await pumpMicrotasks(); + expect(convexApi.setAuthCalls, 1); + + // A second setup queues behind the first, then the provider goes. + unawaited(notifier.reinitializeConvexAuth(source: 'test')); + await pumpMicrotasks(); + container.dispose(); + convexApi.emitAuthState(true); + convexApi.setAuthCompleter!.complete(FakeAuthHandle()); + await Future.delayed(const Duration(milliseconds: 120)); + await pumpMicrotasks(); + + expect(convexApi.setAuthCalls, 1); + }); + + test('a setup the server refuses says Icarus was updated', () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = _upgradeRequired; + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(authProvider); + await pumpMicrotasks(); + + final state = container.read(authProvider); + expect(state.convexAuthStatus, ConvexAuthStatus.incident); + expect(state.errorMessage, clientUpgradeRequiredMessage()); + expect(container.read(clientUpgradeRequiredProvider), isTrue); + }); + + test('a refused setup runs again once the server accepts this build', + () async { + supabaseApi.currentSession = fakeSession(); + convexApi.mutationError = _upgradeRequired; + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider + .overrideWithValue(_AcceptingRepository()), + ]); + addTearDown(container.dispose); + container.read(authProvider); + await pumpMicrotasks(); + expect(container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.incident); + + // A rolled-back deploy accepts this protocol again. + convexApi.mutationError = null; + await container.read(clientUpgradeRequiredProvider.notifier).recheck(); + await pumpMicrotasks(); + await pumpMicrotasks(); + + expect(container.read(clientUpgradeRequiredProvider), isFalse); + expect( + container.read(authProvider).convexAuthStatus, + ConvexAuthStatus.ready, + ); + }); +} + +const _upgradeRequired = ConvexFunctionException( + code: ConvexErrorCode.clientUpgradeRequired, + rawCode: 'CLIENT_UPGRADE_REQUIRED', + message: 'Client upgrade required', +); + +class _AcceptingRepository extends Fake implements ConvexStrategyRepository { + @override + Future serverAcceptsCloudProtocol() async => true; +} + +Future pumpMicrotasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +Session fakeSession({String accessToken = 'test-token'}) { + final session = Session( + accessToken: accessToken, + refreshToken: 'refresh-token', + tokenType: 'bearer', + user: const User( + id: 'user-1', + appMetadata: {}, + userMetadata: {'full_name': 'Test User'}, + aud: 'authenticated', + email: 'test@example.com', + createdAt: '2026-03-23T00:00:00.000Z', + ), + ); + session.expiresAt = DateTime.now() + .toUtc() + .add(const Duration(hours: 1)) + .millisecondsSinceEpoch ~/ + 1000; + return session; +} + +class FakeAuthHandle implements AuthProviderAuthHandle { + bool isDisposed = false; + + @override + void dispose() { + isDisposed = true; + } +} + +class FakeConvexApi implements AuthProviderConvexApi { + FakeConvexApi() : _authStateController = StreamController.broadcast(); + + final StreamController _authStateController; + int clearAuthCalls = 0; + int reconnectCalls = 0; + int setAuthCalls = 0; + int mutationCalls = 0; + String? lastMutationName; + bool autoAuthenticateOnSetAuth = true; + bool _isAuthenticated = false; + Object? mutationError; + Object? reconnectError; + bool reconnectResult = true; + Completer? setAuthCompleter; + final List handles = []; + + @override + Stream get authState => _authStateController.stream; + + @override + bool get isAuthenticated => _isAuthenticated; + + @override + String? get currentConnectionStateLabel => 'connected'; + + @override + Future clearAuth() async { + clearAuthCalls += 1; + _isAuthenticated = false; + _authStateController.add(false); + } + + @override + Future reconnect() async { + reconnectCalls += 1; + if (reconnectError case final Object error?) { + throw error; + } + return reconnectResult; + } + + @override + Future ensureCurrentUser() async { + mutationCalls += 1; + lastMutationName = 'users:ensureCurrentUser'; + if (mutationError case final Object error?) { + throw error; + } + } + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + setAuthCalls += 1; + final completer = setAuthCompleter; + if (completer != null) { + return completer.future; + } + if (autoAuthenticateOnSetAuth) { + emitAuthState(true); + onAuthChange?.call(true); + } + final handle = FakeAuthHandle(); + handles.add(handle); + return handle; + } + + void emitAuthState(bool isAuthenticated) { + _isAuthenticated = isAuthenticated; + _authStateController.add(isAuthenticated); + } +} + +class FakeSupabaseApi implements AuthProviderSupabaseApi { + @override + Session? currentSession; + bool emitInitialSessionOnListen = false; + bool emitSignedInEventOnPasswordSignIn = false; + Session? sessionFromUrlSession; + Object? sessionFromUrlError; + int getSessionFromUrlCalls = 0; + final List> _controllers = + >[]; + + @override + Stream get onAuthStateChange => Stream.multi( + (controller) { + _controllers.add(controller); + if (emitInitialSessionOnListen) { + controller.add( + AuthState(AuthChangeEvent.initialSession, currentSession), + ); + } + controller.onCancel = () { + _controllers.remove(controller); + }; + }, + isBroadcast: true, + ); + + @override + Future getSessionFromUrl(Uri uri) async { + getSessionFromUrlCalls += 1; + if (sessionFromUrlError case final Object error?) { + throw error; + } + if (sessionFromUrlSession case final Session session?) { + currentSession = session; + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedIn, currentSession)); + } + } + } + + @override + Future refreshSession() async { + return AuthResponse(session: currentSession); + } + + @override + Future signInWithOAuth( + OAuthProvider provider, { + required String redirectTo, + required LaunchMode authScreenLaunchMode, + required String scopes, + }) async { + return true; + } + + @override + Future signInWithPassword({ + required String email, + required String password, + }) async { + if (emitSignedInEventOnPasswordSignIn) { + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedIn, currentSession)); + } + } + return AuthResponse(session: currentSession); + } + + @override + Future signOut() async { + currentSession = null; + for (final controller in _controllers) { + controller.add(AuthState(AuthChangeEvent.signedOut, currentSession)); + } + } + + @override + Future signUp({ + required String email, + required String password, + }) async { + return AuthResponse(session: currentSession); + } + + void emit(AuthChangeEvent event, Session? session) { + for (final controller in _controllers) { + controller.add(AuthState(event, session)); + } + } + + Future dispose() async {} +} diff --git a/test/providers/cloud_library_action_providers_test.dart b/test/providers/cloud_library_action_providers_test.dart new file mode 100644 index 00000000..67af0f2f --- /dev/null +++ b/test/providers/cloud_library_action_providers_test.dart @@ -0,0 +1,536 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDirectory; + + setUp(() async { + tempDirectory = await Directory.systemTemp.createTemp( + 'icarus-cloud-library-actions-', + ); + Hive.init(tempDirectory.path); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + }); + + tearDown(() async { + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('failed cloud folder delete preserves selection, pin, and streams', + () async { + final repository = _ActionRepository()..failDeleteFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, "Couldn't delete this cloud folder. Try again."); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(folderProvider), 'folder-1'); + expect(harness.container.read(pinnedItemsProvider), contains('folder-1')); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('successful cloud folder delete mutates local UI state once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + final notifier = harness.container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('folder-1'); + + final result = await notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteFolderCalls, 1); + expect(harness.container.read(folderProvider), isNull); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('folder-1')), + ); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('cloud folder update is awaitable and invalidates only after success', + () async { + final gate = Completer(); + final repository = _ActionRepository()..updateFolderGate = gate; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final pending = harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Retakes', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + expect(harness.cloudFolderBuilds, 1); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.updateFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + }); + + test('failed cloud folder update preserves model and does not invalidate', + () async { + final repository = _ActionRepository()..failUpdateFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + final folder = _folder('folder-1'); + + final result = + await harness.container.read(folderProvider.notifier).editFolder( + folder: folder, + newName: 'Injected ${_ActionRepository.secret}', + newIconId: folder.iconId, + newColor: folder.color, + newCustomColor: folder.customColor, + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(folder.name, 'Defaults'); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test('failed cloud folder move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveFolderGate = gate + ..failMoveFolder = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudFolderBuilds, 1); + expect(harness.allCloudFolderBuilds, 1); + expect(harness.messages, ["Couldn't move this cloud folder. Try again."]); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud folder move refreshes both folder views once', + () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(folderProvider.notifier).moveToFolder( + folderID: 'folder-1', + parentID: 'folder-2', + workspace: LibraryWorkspace.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveFolderCalls, 1); + expect(harness.cloudFolderBuilds, 2); + expect(harness.allCloudFolderBuilds, 2); + expect(harness.messages, isEmpty); + }); + + test('cloud strategy delete keeps pin and stream when the server rejects it', + () async { + final repository = _ActionRepository()..failDeleteStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(result.userMessage, isNot(contains(_ActionRepository.secret))); + expect(harness.container.read(pinnedItemsProvider), contains('strategy-1')); + expect(harness.cloudStrategyBuilds, 1); + expect(harness.messages, isEmpty); + }); + + test( + 'cloud strategy delete removes pin, unsent edits, and refreshes only ' + 'after success', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + await harness.container + .read(pinnedItemsProvider.notifier) + .togglePin('strategy-1'); + // An unsent edit to the strategy about to be deleted: it can never land. + final now = DateTime(2026); + await harness.outbox.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: const PendingOp( + op: ElementPatchOp( + opId: 'edit', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'unsent'}, + expectedElementRevision: 1, + ), + clientId: 'client-1', + ), + status: DurableOutboxStatus.queued, + createdAt: now, + updatedAt: now, + )); + harness.container + .read(strategyOpQueueProvider.notifier) + .setCurrentAccount('account-a'); + + final result = await harness.container + .read(strategyProvider.notifier) + .deleteStrategy('strategy-1', source: StrategySource.cloud); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.deleteStrategyCalls, 1); + expect( + harness.container.read(pinnedItemsProvider), + isNot(contains('strategy-1')), + ); + expect(harness.outbox.load().records, isEmpty); + expect(harness.cloudStrategyBuilds, 2); + }); + + test('failed cloud strategy move is awaitable, visible, and does not refresh', + () async { + final gate = Completer(); + final repository = _ActionRepository() + ..moveStrategyGate = gate + ..failMoveStrategy = true; + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final pending = + harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + var completed = false; + pending.then((_) => completed = true); + await _pumpMicrotasks(); + expect(completed, isFalse); + + gate.complete(); + final result = await pending; + await _pumpMicrotasks(); + + expect(result.didSucceed, isFalse); + expect(harness.cloudStrategyBuilds, 1); + expect( + harness.messages, + ["Couldn't move this cloud strategy. Try again."], + ); + expect(harness.messages.single, isNot(contains(_ActionRepository.secret))); + }); + + test('successful cloud strategy move refreshes the library once', () async { + final repository = _ActionRepository(); + final harness = _Harness(repository); + addTearDown(harness.dispose); + + final result = + await harness.container.read(strategyProvider.notifier).moveToFolder( + strategyID: 'strategy-1', + parentID: 'folder-2', + source: StrategySource.cloud, + ); + await _pumpMicrotasks(); + + expect(result.didSucceed, isTrue); + expect(repository.moveStrategyCalls, 1); + expect(harness.cloudStrategyBuilds, 2); + expect(harness.messages, isEmpty); + }); +} + +class _Harness { + final outbox = MemoryDurableStrategyOutboxStore(); + + _Harness(this.repository) { + container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => auth), + durableStrategyOutboxStoreProvider.overrideWithValue(outbox), + durableCloudMediaOutboxStoreProvider + .overrideWithValue(MemoryDurableCloudMediaOutboxStore()), + pendingMediaBytesStoreProvider + .overrideWithValue(MemoryPendingMediaBytesStore()), + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + cloudFoldersProvider.overrideWith((_) { + cloudFolderBuilds += 1; + return const AsyncData([]); + }), + cloudAllFoldersProvider.overrideWith((_) { + allCloudFolderBuilds += 1; + return Stream.value(const []); + }), + cloudStrategiesProvider.overrideWith((_) { + cloudStrategyBuilds += 1; + return Stream.value(const []); + }), + ], + ); + container.listen(cloudFoldersProvider, (_, __) {}, fireImmediately: true); + container.listen( + cloudAllFoldersProvider, + (_, __) {}, + fireImmediately: true, + ); + container.listen( + cloudStrategiesProvider, + (_, __) {}, + fireImmediately: true, + ); + } + + final _ActionRepository repository; + final _ReadyAuthProvider auth = _ReadyAuthProvider(); + final List messages = []; + late final ProviderContainer container; + int cloudFolderBuilds = 0; + int allCloudFolderBuilds = 0; + int cloudStrategyBuilds = 0; + + void dispose() => container.dispose(); +} + +class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { + @override + LibraryWorkspace build() => LibraryWorkspace.cloud; +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _ActionRepository extends ConvexStrategyRepository { + _ActionRepository() : super(IcarusConvexApi(_UnusedTransport())); + + static const secret = 'Bearer super-secret-backend-detail'; + + bool failDeleteFolder = false; + bool failUpdateFolder = false; + bool failMoveFolder = false; + bool failDeleteStrategy = false; + bool failMoveStrategy = false; + Completer? updateFolderGate; + Completer? moveFolderGate; + Completer? moveStrategyGate; + int deleteFolderCalls = 0; + int updateFolderCalls = 0; + int deleteStrategyCalls = 0; + int moveFolderCalls = 0; + int moveStrategyCalls = 0; + + @override + Future deleteFolder(String folderPublicId) async { + deleteFolderCalls += 1; + if (failDeleteFolder) throw StateError(secret); + } + + @override + Future updateFolder({ + required String folderPublicId, + String? name, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + bool clearIconFontFamily = false, + String? iconFontPackage, + bool clearIconFontPackage = false, + String? color, + int? customColorValue, + bool clearCustomColorValue = false, + }) async { + updateFolderCalls += 1; + await updateFolderGate?.future; + if (failUpdateFolder) throw StateError(secret); + } + + @override + Future moveFolder({ + required String folderPublicId, + String? parentFolderPublicId, + }) async { + moveFolderCalls += 1; + await moveFolderGate?.future; + if (failMoveFolder) throw StateError(secret); + } + + @override + Future fetchShell( + String strategyPublicId, { + String? shareToken, + }) async { + final now = DateTime.utc(2026); + return RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: strategyPublicId, + name: 'A Split', + mapData: 'Ascent', + revision: 4, + createdAt: now, + updatedAt: now, + ), + pages: const [], + ); + } + + @override + Future deleteStrategy({ + required String strategyPublicId, + required int expectedRevision, + }) async { + deleteStrategyCalls += 1; + if (failDeleteStrategy) throw StateError(secret); + } + + @override + Future moveStrategy({ + required String strategyPublicId, + String? folderPublicId, + required int expectedRevision, + }) async { + moveStrategyCalls += 1; + await moveStrategyGate?.future; + if (failMoveStrategy) throw StateError(secret); + } +} + +Folder _folder(String id) => Folder( + id: id, + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + ); + +Future _pumpMicrotasks() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/providers/cloud_migration_provider_test.dart b/test/providers/cloud_migration_provider_test.dart new file mode 100644 index 00000000..a7f134a6 --- /dev/null +++ b/test/providers/cloud_migration_provider_test.dart @@ -0,0 +1,204 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/migrations/canonical_coordinates_migration.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_migration_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +bool _adaptersRegistered = false; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDirectory; + late ProviderContainer container; + late FakeCloudMigrationApi api; + + setUp(() async { + tempDirectory = + await Directory.systemTemp.createTemp('icarus-cloud-migration-'); + Hive.init(tempDirectory.path); + if (!_adaptersRegistered) { + registerIcarusAdapters(Hive); + _adaptersRegistered = true; + } + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.strategiesBox); + + api = FakeCloudMigrationApi(); + container = ProviderContainer( + overrides: [ + isCloudCollabEnabledProvider.overrideWithValue(true), + cloudMigrationApiProvider.overrideWithValue(api), + ], + ); + }); + + tearDown(() async { + container.dispose(); + await Hive.close(); + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('failed remote write stays incomplete and retries in the same session', + () async { + await Hive.box(HiveBoxNames.foldersBox).add( + Folder( + name: 'Retry me', + id: 'folder-1', + dateCreated: DateTime.utc(2026), + ), + ); + api.folderFailuresRemaining = 1; + + final notifier = container.read(cloudMigrationProvider.notifier); + await notifier.maybeMigrate(); + + expect(container.read(cloudMigrationProvider), isFalse); + expect(api.createFolderCalls, 1); + + await notifier.maybeMigrate(); + + expect(container.read(cloudMigrationProvider), isTrue); + expect(api.createFolderCalls, 2); + + await notifier.maybeMigrate(); + expect(api.createFolderCalls, 2); + }); + + test('migrates legacy defense coordinates before the first cloud upload', + () async { + const legacyPosition = Offset(1400, 700); + const virtualToWorld = 1000 / 831; + final agentAnchorWorld = const Offset( + Settings.agentSize / 2, + Settings.agentSize / 2, + ) * + virtualToWorld; + final expectedCanonicalPosition = Offset( + 1000 * (16 / 9) - legacyPosition.dx - (agentAnchorWorld.dx * 2), + 1000 - legacyPosition.dy - (agentAnchorWorld.dy * 2), + ); + final legacyStrategy = StrategyData( + id: 'legacy-defense-strategy', + name: 'Legacy defense strategy', + mapData: MapValue.ascent, + versionNumber: CanonicalCoordinatesMigration.version - 1, + lastEdited: DateTime.utc(2026), + folderID: null, + pages: [ + StrategyPage( + id: 'defense-page', + name: 'Defense', + drawingData: const [], + agentData: [ + PlacedAgent( + id: 'agent-1', + type: AgentType.jett, + position: legacyPosition, + ), + ], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: false, + settings: StrategySettings(), + ), + ], + ); + final strategiesBox = Hive.box(HiveBoxNames.strategiesBox); + await strategiesBox.put(legacyStrategy.id, legacyStrategy); + + await container.read(cloudMigrationProvider.notifier).maybeMigrate(); + + expect(container.read(cloudMigrationProvider), isTrue); + final storedStrategy = strategiesBox.get(legacyStrategy.id)!; + expect(storedStrategy.versionNumber, Settings.versionNumber); + expect( + storedStrategy.pages.single.agentData.single.position, + expectedCanonicalPosition, + ); + + final uploadedOp = api.appliedBatches.single.single as ElementAddOp; + final uploadedAgent = PlacedAgent.fromJson( + cloudPayloadData(uploadedOp.payload), + ); + expect(uploadedAgent.position, expectedCanonicalPosition); + }); +} + +class FakeCloudMigrationApi implements CloudMigrationApi { + int folderFailuresRemaining = 0; + int createFolderCalls = 0; + final List> appliedBatches = []; + + @override + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + }) async { + createFolderCalls += 1; + if (folderFailuresRemaining > 0) { + folderFailuresRemaining -= 1; + throw StateError('temporary cloud failure'); + } + } + + @override + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + bool? initialPageIsAutoNamed, + required bool initialPageIsAttack, + required Map initialPageSettings, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + }) async {} + + @override + Future addPage({ + required String strategyPublicId, + required String pagePublicId, + required String name, + bool? isAutoNamed, + required int sortIndex, + required bool isAttack, + required Map settings, + required int expectedRevision, + }) async {} + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + }) async { + appliedBatches.add(List.from(ops)); + return [ + for (final op in ops) NoopOpAck(opId: op.opId), + ]; + } +} diff --git a/test/providers/folder_provider_test.dart b/test/providers/folder_provider_test.dart new file mode 100644 index 00000000..430b2235 --- /dev/null +++ b/test/providers/folder_provider_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/pinned_items_provider.dart'; + +void main() { + test('failed cloud folder deletion preserves the selected folder', () async { + final repository = _DeleteFolderRepository(shouldFail: true); + final container = _createContainer(repository); + addTearDown(container.dispose); + + final notifier = container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + + notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await repository.deleteAttempted; + await Future.delayed(Duration.zero); + + expect(container.read(folderProvider), 'folder-1'); + }); + + test('successful cloud folder deletion clears the selected folder', () async { + final repository = _DeleteFolderRepository(); + final container = _createContainer(repository); + addTearDown(container.dispose); + + final notifier = container.read(folderProvider.notifier); + notifier.updateID('folder-1'); + + notifier.deleteFolder( + 'folder-1', + workspace: LibraryWorkspace.cloud, + ); + await repository.deleteAttempted; + await Future.delayed(Duration.zero); + + expect(container.read(folderProvider), isNull); + }); +} + +ProviderContainer _createContainer(ConvexStrategyRepository repository) { + return ProviderContainer( + overrides: [ + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + pinnedItemsProvider.overrideWith(_MemoryPinnedItemsProvider.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + cloudFoldersProvider.overrideWith((_) => const AsyncData([])), + cloudAllFoldersProvider.overrideWith((_) => Stream.value(const [])), + cloudStrategiesProvider.overrideWith((_) => Stream.value(const [])), + ], + ); +} + +class _MemoryPinnedItemsProvider extends PinnedItemsProvider { + @override + Map build() => const {}; + + @override + Future removePin(String id) async {} +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { + @override + LibraryWorkspace build() => LibraryWorkspace.cloud; +} + +class _DeleteFolderRepository extends ConvexStrategyRepository { + _DeleteFolderRepository({this.shouldFail = false}) + : super(IcarusConvexApi(_UnusedTransport())); + + final bool shouldFail; + final _deleteAttempted = Completer(); + + Future get deleteAttempted => _deleteAttempted.future; + + @override + Future deleteFolder(String folderPublicId) async { + _deleteAttempted.complete(); + if (shouldFail) { + throw StateError('delete rejected'); + } + } +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/providers/lineup_editing_presence_provider_test.dart b/test/providers/lineup_editing_presence_provider_test.dart new file mode 100644 index 00000000..9d59d826 --- /dev/null +++ b/test/providers/lineup_editing_presence_provider_test.dart @@ -0,0 +1,193 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/presence/presence_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; + +/// The editor showing page p1, without the session's cloud machinery. +class _FixedSession extends StrategyPageSessionNotifier { + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'p1', + availablePageIds: ['p1', 'p2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); +} + +/// Two lineups, each from its own origin to its own landing: two groups. +LineUpGraph _graph() { + final ability = AgentData.agents[AgentType.breach]!.abilities.first; + LineUpOrigin origin(String id) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: '$id-agent', + type: AgentType.breach, + position: const Offset(20, 20), + ), + ); + LineUpLanding landing(String id) => LineUpLanding( + id: id, + ability: PlacedAbility( + id: '$id-ability', + data: ability, + position: const Offset(50, 50), + ), + ); + return LineUpGraph( + origins: [origin('origin-a'), origin('origin-b')], + landings: [landing('landing-a'), landing('landing-b')], + links: [ + LineUpLink(id: 'link-a', originId: 'origin-a', landingId: 'landing-a'), + LineUpLink(id: 'link-b', originId: 'origin-b', landingId: 'landing-b'), + ], + ); +} + +void main() { + late ProviderContainer container; + + setUp(() { + container = ProviderContainer(overrides: [ + strategyPageSessionProvider.overrideWith(_FixedSession.new), + ]); + addTearDown(container.dispose); + container.read(lineUpProvider.notifier).fromHive(_graph()); + container.read(activePageLiveSyncProvider.notifier) + ..noteLineupGroups('p1', { + 'origin-a': 'group-a', + 'landing-a': 'group-a', + 'link-a': 'group-a', + 'origin-b': 'group-b', + 'landing-b': 'group-b', + 'link-b': 'group-b', + }) + // The same lineups on a duplicated page are another page's groups. + ..noteLineupGroups('p2', {'landing-a': 'group-a2'}); + }); + + PresenceEditing? editing() => container.read(myLineupEditingProvider); + + test('nothing edited is null', () { + expect(editing(), isNull); + }); + + test('a held lineup spot is editing its group', () { + container.read(editorPointersProvider.notifier).holdEntity(1, 'landing-a'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + }); + + test('held items that are not lineups are ignored', () { + final pointers = container.read(editorPointersProvider.notifier); + pointers.holdEntity(1, 'some-agent'); + expect(editing(), isNull); + pointers.holdEntity(1, 'origin-b'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + }); + + test('placing from a pinned spot is editing its group', () { + container.read(lineUpProvider.notifier).startFromOrigin('origin-b'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + container.read(lineUpProvider.notifier).startToLanding('landing-a'); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + // A fresh lineup belongs to no group yet. + container.read(lineUpProvider.notifier).startFresh(); + expect(editing(), isNull); + }); + + test('an open dialog is editing the groups of the items it shows', () { + final open = container.read(openLineUpItemsProvider.notifier); + final panel = Object(); + final editor = Object(); + open.open(panel, {'landing-a'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a'}), + ); + open.open(editor, {'link-b', 'not-a-lineup'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a', 'group-b'}), + ); + open.close(panel); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-b'}), + ); + open.close(editor); + expect(container.read(openLineUpItemsProvider), isEmpty); + expect(editing(), isNull); + }); + + test('held, placed and open items together name each group once', () { + container.read(editorPointersProvider.notifier).holdEntity(1, 'link-a'); + container.read(lineUpProvider.notifier).startToLanding('landing-a'); + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'origin-b'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-a', 'group-b'}), + ); + }); + + test('a group learned while a dialog is open is edited from then on', () { + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'link-new'}); + expect(editing(), isNull); + // The lineup's group arrives (its first sync, or the page redrawn from + // the server) while the dialog stays open. + container + .read(activePageLiveSyncProvider.notifier) + .noteLineupGroups('p1', {'link-new': 'group-new'}); + expect( + editing(), + const PresenceEditing(pageId: 'p1', groupIds: {'group-new'}), + ); + }); + + test('a dialog edits the groups of the page it opened on, only there', () { + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'landing-a', 'landing-b'}); + final session = container.read(strategyPageSessionProvider.notifier); + session.setStateForTest( + container.read(strategyPageSessionProvider).copyWith(activePageId: 'p2'), + ); + // Page 2 has lineups under the same ids, in other groups; the dialog + // still shows page 1's, so it edits nothing here. + expect(editing(), isNull); + + container + .read(openLineUpItemsProvider.notifier) + .open(Object(), {'landing-a'}); + expect( + editing(), + const PresenceEditing(pageId: 'p2', groupIds: {'group-a2'}), + ); + session.setStateForTest( + container + .read(strategyPageSessionProvider) + .copyWith(clearActivePageId: true), + ); + expect(editing(), isNull); + }); +} diff --git a/test/providers/remote_library_provider_test.dart b/test/providers/remote_library_provider_test.dart new file mode 100644 index 00000000..407b9e70 --- /dev/null +++ b/test/providers/remote_library_provider_test.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/domain/folder.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; + +void main() { + test('repository maps the typed folder tree into Icarus folders', () async { + final transport = _RecordingTransport(); + final repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + final firstTree = repository.watchAllFolders().first; + await Future.delayed(Duration.zero); + + transport.emitSubscription( + 'folders:listTree', + ConvexValue.fromDart([ + { + 'publicId': 'folder-1', + 'name': 'Defaults', + 'iconId': 7, + 'iconCodePoint': 0xe2c7, + 'iconFontFamily': 'MaterialIcons', + 'iconFontPackage': null, + 'color': 'blue', + 'customColorValue': 0xff123456, + 'parentFolderPublicId': null, + 'createdAt': 1700000000000, + 'updatedAt': 1700000001000, + 'role': 'owner', + 'strategyCount': 2, + 'mapPeeks': ['haven', 'ascent'], + 'agentTypes': ['sova', 'jett', 'not-an-agent-yet'], + }, + ]), + ); + + final folders = await firstTree; + expect(transport.subscriptionCount['folders:listTree'], 1); + expect(transport.lastArgs['folders:listTree']?.toDart(), {'scope': 'all'}); + expect(folders.single.folder.id, 'folder-1'); + expect(folders.single.folder.iconId, 7); + expect(folders.single.folder.color, FolderColor.blue); + expect(folders.single.folder.customColor?.toARGB32(), 0xff123456); + expect(folders.single.role, 'owner'); + expect(folders.single.strategyCount, 2); + expect(folders.single.mapPeeks, [MapValue.haven, MapValue.ascent]); + // Unknown agent names from a newer server are skipped, not fatal. + expect(folders.single.agentTypes, [AgentType.sova, AgentType.jett]); + }); + + test('repository maps typed strategy rows into Icarus strategies', () async { + final transport = _RecordingTransport(); + final repository = ConvexStrategyRepository(IcarusConvexApi(transport)); + final firstList = repository.watchStrategiesForFolder(null).first; + await Future.delayed(Duration.zero); + + transport.emitSubscription( + 'strategies:listForFolder', + ConvexValue.fromDart([ + { + 'publicId': 'strategy-1', + 'name': 'A Split', + 'mapData': 'ascent', + 'folderPublicId': null, + 'revision': 4, + 'createdAt': 1700000000000, + 'updatedAt': 1700000001000, + 'themeProfileId': null, + 'themeOverridePalette': null, + 'role': 'owner', + 'attackLabel': 'Attack', + }, + ]), + ); + + final entry = (await firstList).single; + expect(entry.strategy.id, 'strategy-1'); + expect(entry.strategy.mapData, MapValue.ascent); + expect(entry.strategy.folderID, isNull); + expect( + entry.strategy.lastEdited, + DateTime.fromMillisecondsSinceEpoch(1700000001000), + ); + expect(entry.revision, 4); + expect(entry.role, 'owner'); + expect(entry.attackLabel, 'Attack'); + }); + + test('folder views share one cached tree subscription', () async { + final repository = _CountingRepository(); + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(_ReadyAuthProvider.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + + final treeSubscription = container.listen( + cloudFolderTreeProvider, + (_, __) {}, + fireImmediately: true, + ); + final allSubscription = container.listen( + cloudAllFoldersProvider, + (_, __) {}, + fireImmediately: true, + ); + final childrenSubscription = container.listen( + cloudFoldersProvider, + (_, __) {}, + fireImmediately: true, + ); + addTearDown(treeSubscription.close); + addTearDown(allSubscription.close); + addTearDown(childrenSubscription.close); + await Future.delayed(Duration.zero); + + expect(repository.folderWatchCount, 1); + + repository.folders.add([ + ( + folder: Folder( + id: 'owned-root', + name: 'Owned', + dateCreated: DateTime(2026), + ), + role: 'owner', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], + ), + ( + folder: Folder( + id: 'shared-root', + name: 'Shared', + dateCreated: DateTime(2026), + ), + role: 'editor', + strategyCount: 0, + mapPeeks: const [], + agentTypes: const [], + ), + ]); + for (var i = 0; + i < 20 && container.read(cloudFoldersProvider).valueOrNull == null; + i++) { + await Future.delayed(const Duration(milliseconds: 1)); + } + + expect( + container + .read(cloudFoldersProvider) + .valueOrNull + ?.map((entry) => entry.folder.id), + ['owned-root'], + ); + }); + + test('sign-out ignores an unauthenticated error from the stale stream', + () async { + final repository = _StrategyStreamRepository(); + final auth = _MutableAuthProvider(); + final providerErrors = []; + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(() => auth), + convexStrategyRepositoryProvider.overrideWithValue(repository), + ], + ); + addTearDown(container.dispose); + + final subscription = container.listen( + cloudStrategiesProvider, + (_, next) { + if (next case AsyncError(:final error)) { + providerErrors.add(error); + } + }, + fireImmediately: true, + ); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + + auth.signOutForTest(); + repository.strategies.addError( + const ConvexFunctionException( + code: ConvexErrorCode.unauthenticated, + rawCode: 'UNAUTHENTICATED', + message: 'Unauthenticated', + ), + ); + await Future.delayed(Duration.zero); + + expect(providerErrors.whereType(), isEmpty); + expect(auth.unauthenticatedReports, 1); + expect(container.read(cloudStrategiesProvider).valueOrNull, isEmpty); + }); +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _CountingRepository extends ConvexStrategyRepository { + _CountingRepository() : super(IcarusConvexApi(_RecordingTransport())); + + final folders = StreamController>.broadcast(); + int folderWatchCount = 0; + + @override + Stream> watchAllFolders() { + folderWatchCount += 1; + return folders.stream; + } +} + +class _StrategyStreamRepository extends ConvexStrategyRepository { + _StrategyStreamRepository() + : strategies = + StreamController>.broadcast(sync: true), + super(IcarusConvexApi(_RecordingTransport())); + + final StreamController> strategies; + + @override + Stream> watchStrategiesForFolder( + String? folderPublicId, { + String scope = 'owned', + }) { + return strategies.stream; + } +} + +class _MutableAuthProvider extends AuthProvider { + int unauthenticatedReports = 0; + + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); + + void signOutForTest() { + state = AppAuthState.fromSession( + null, + isLoading: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + ); + } + + @override + Future reportConvexUnauthenticated({ + required String source, + Object? error, + StackTrace? stackTrace, + }) async { + unauthenticatedReports++; + } +} + +class _RecordingTransport implements ConvexTransport { + final subscriptionCount = {}; + final lastArgs = {}; + final _subscriptions = >{}; + + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) { + subscriptionCount.update(name, (count) => count + 1, ifAbsent: () => 1); + lastArgs[name] = args; + return _subscriptions + .putIfAbsent(name, StreamController.broadcast) + .stream; + } + + void emitSubscription(String name, ConvexValue value) { + _subscriptions[name]!.add(value); + } +} diff --git a/test/providers/strategy_duplicate_test.dart b/test/providers/strategy_duplicate_test.dart new file mode 100644 index 00000000..d9dbb719 --- /dev/null +++ b/test/providers/strategy_duplicate_test.dart @@ -0,0 +1,289 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +void main() { + group('cloud duplicate', () { + test('asks the server for the whole copy in one mutation', () async { + final transport = _RecordingTransport(); + final messages = []; + final container = _container(transport, messages); + addTearDown(container.dispose); + + await container.read(strategyProvider.notifier).duplicateStrategy( + 'source-strategy', + source: StrategySource.cloud, + ); + + expect(transport.mutations, hasLength(1)); + final (name, args) = transport.mutations.single; + expect(name, 'strategies:duplicate'); + expect(args['sourceStrategyPublicId'], 'source-strategy'); + expect(args['name'], 'A site execute (Copy)'); + expect(args['folderPublicId'], 'current-folder'); + expect(args['publicId'], isA()); + expect(args['publicId'], isNot('source-strategy')); + expect(messages, isEmpty); + expect(transport.libraryListBuilds, 1, reason: 'the library refreshes'); + }); + + test( + 'an old server without the mutation shows a message and sends nothing else', + () async { + final transport = _RecordingTransport( + mutationError: const ConvexTransportError( + rawCode: 'Server Error', + message: "Could not find public function for 'strategies:duplicate'", + ), + ); + final messages = []; + final container = _container(transport, messages); + addTearDown(container.dispose); + + await container.read(strategyProvider.notifier).duplicateStrategy( + 'source-strategy', + source: StrategySource.cloud, + ); + + expect(transport.mutations.map((call) => call.$1), [ + 'strategies:duplicate', + ]); + expect(messages, ["Couldn't duplicate this strategy. Try again."]); + expect(transport.libraryListBuilds, 0); + }); + test('a strategy too large to copy says so instead of asking to retry', + () async { + final transport = _RecordingTransport( + mutationError: const ConvexTransportError( + rawCode: 'STRATEGY_TOO_LARGE_TO_DUPLICATE', + message: 'This strategy is too large to duplicate.', + ), + ); + final messages = []; + final container = _container(transport, messages); + addTearDown(container.dispose); + + await container.read(strategyProvider.notifier).duplicateStrategy( + 'source-strategy', + source: StrategySource.cloud, + ); + + expect(transport.mutations.map((call) => call.$1), [ + 'strategies:duplicate', + ]); + expect(messages, ['This strategy is too large to duplicate.']); + expect(transport.libraryListBuilds, 0); + }); + + test('an image this device has not uploaded yet holds the duplicate', + () async { + final transport = _RecordingTransport(); + final messages = []; + final container = _container( + transport, + messages, + unsentImages: [ + CloudMediaUploadJob( + jobId: 'job-1', + accountId: 'account-1', + strategyPublicId: 'source-strategy', + assetPublicId: 'image-1', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + ); + addTearDown(container.dispose); + + await container.read(strategyProvider.notifier).duplicateStrategy( + 'source-strategy', + source: StrategySource.cloud, + ); + + expect(transport.mutations, isEmpty); + expect(messages, [ + "This strategy has images that haven't finished uploading. " + "Duplicate it once it's synced.", + ]); + }); + }); + + group('local duplicate', () { + late Directory hiveDir; + + setUp(() async { + hiveDir = await Directory.systemTemp.createTemp('duplicate_test'); + Hive.init(hiveDir.path); + registerIcarusAdapters(Hive); + await Hive.openBox(HiveBoxNames.strategiesBox); + }); + + tearDown(() async { + await Hive.close(); + await hiveDir.delete(recursive: true); + }); + + test('stays on this device and never calls the server', () async { + final box = Hive.box(HiveBoxNames.strategiesBox); + await box.put( + 'local-strategy', + StrategyData( + id: 'local-strategy', + name: 'Bind B split', + mapData: MapValue.bind, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.utc(2026), + folderID: 'local-folder', + pages: const [], + ), + ); + final transport = _RecordingTransport(); + final container = _container(transport, []); + addTearDown(container.dispose); + + await container.read(strategyProvider.notifier).duplicateStrategy( + 'local-strategy', + source: StrategySource.local, + ); + + final copy = box.values.singleWhere((s) => s.id != 'local-strategy'); + expect(copy.name, 'Bind B split (Copy)'); + expect(copy.folderID, 'local-folder'); + expect(copy.mapData, MapValue.bind); + expect(transport.queries, isEmpty); + expect(transport.mutations, isEmpty); + }); + }); +} + +ProviderContainer _container( + _RecordingTransport transport, + List messages, { + List unsentImages = const [], +}) { + return ProviderContainer(overrides: [ + cloudMediaUploadQueueProvider.overrideWith(() => _Queue(unsentImages)), + cloudStrategiesProvider.overrideWith((ref) { + transport.libraryListBuilds += 1; + return const Stream>.empty(); + }), + strategyProvider.overrideWith(_ClosedStrategy.new), + folderProvider.overrideWith(_CurrentFolder.new), + convexStrategyRepositoryProvider + .overrideWithValue(_ShellRepository(transport)), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + ]); +} + +class _ClosedStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: null, + strategyName: null, + source: StrategySource.local, + isOpen: false, + ); +} + +class _Queue extends CloudMediaUploadQueueNotifier { + _Queue(this.jobs); + + final List jobs; + + @override + CloudMediaUploadQueueState build() => + CloudMediaUploadQueueState(jobs: jobs, isProcessing: false); +} + +class _CurrentFolder extends FolderProvider { + @override + String? build() => 'current-folder'; +} + +/// The real repository over a recording transport, so the test sees the +/// exact wire call; only the shell read is canned. +class _ShellRepository extends ConvexStrategyRepository { + _ShellRepository(_RecordingTransport transport) + : super(IcarusConvexApi(transport)); + + @override + Future fetchShell( + String strategyPublicId, { + String? shareToken, + }) async => + RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: strategyPublicId, + name: 'A site execute', + mapData: 'Ascent', + revision: 3, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + pages: const [], + ); +} + +class _RecordingTransport implements ConvexTransport { + _RecordingTransport({this.mutationError}); + + final ConvexTransportError? mutationError; + final queries = []; + + /// How many times the cloud library list was rebuilt. Kept here so each + /// test's container has its own count. + var libraryListBuilds = 0; + final mutations = <(String, Map)>[]; + + @override + Future query(String name, ConvexObject args) async { + queries.add(name); + throw UnimplementedError(name); + } + + @override + Future mutation(String name, ConvexObject args) async { + mutations.add((name, args.toDart())); + final error = mutationError; + if (error != null) throw error; + return ConvexObject({'ok': const ConvexBoolean(true)}); + } + + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(name); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(name); +} diff --git a/test/providers/strategy_rename_test.dart b/test/providers/strategy_rename_test.dart new file mode 100644 index 00000000..a2a2e28b --- /dev/null +++ b/test/providers/strategy_rename_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; + +void main() { + test('renaming the open cloud strategy updates the name the title reads', + () async { + final repository = _RenameRepository(); + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(() => _OpenStrategy(StrategySource.cloud)), + remoteEditorSnapshotProvider.overrideWith(_OpenSnapshot.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + ]); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + + await container.read(strategyProvider.notifier).renameStrategy( + 'strategy-1', + 'Renamed', + source: StrategySource.cloud, + ); + + expect(repository.renamedTo, 'Renamed'); + expect(repository.expectedRevision, 4); + expect(container.read(strategyProvider).strategyName, 'Renamed'); + }); + + group('local (desktop)', () { + late Directory hiveDir; + + setUp(() async { + hiveDir = await Directory.systemTemp.createTemp('rename_test'); + Hive.init(hiveDir.path); + registerIcarusAdapters(Hive); + await Hive.openBox(HiveBoxNames.strategiesBox); + }); + + tearDown(() async { + await Hive.close(); + await hiveDir.delete(recursive: true); + }); + + test('renaming the open local strategy updates the name the title reads', + () async { + final box = Hive.box(HiveBoxNames.strategiesBox); + await box.put('strategy-1', _localStrategy('strategy-1', 'Original')); + final container = ProviderContainer(overrides: [ + strategyProvider + .overrideWith(() => _OpenStrategy(StrategySource.local)), + ]); + addTearDown(container.dispose); + container.read(strategyProvider); + + await container.read(strategyProvider.notifier).renameStrategy( + 'strategy-1', + 'Renamed', + source: StrategySource.local, + ); + + expect(box.get('strategy-1')!.name, 'Renamed'); + expect(container.read(strategyProvider).strategyName, 'Renamed'); + }); + }); +} + +class _OpenStrategy extends StrategyProvider { + _OpenStrategy(this.source); + + final StrategySource source; + + @override + StrategyState build() => StrategyState( + strategyId: 'strategy-1', + strategyName: 'Original', + source: source, + isOpen: true, + ); +} + +class _OpenSnapshot extends RemoteEditorSnapshotNotifier { + @override + Future build() async => RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy-1', + name: 'Original', + mapData: 'Ascent', + revision: 4, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + pages: const [], + ), + activePage: null, + ); + + @override + Future refresh() async {} +} + +class _RenameRepository extends ConvexStrategyRepository { + _RenameRepository() : super(IcarusConvexApi(_UnusedTransport())); + + String? renamedTo; + int? expectedRevision; + + @override + Future updateStrategyName({ + required String strategyPublicId, + required String name, + required int expectedRevision, + }) async { + renamedTo = name; + this.expectedRevision = expectedRevision; + } +} + +class _UnusedTransport implements ConvexTransport { + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} + +StrategyData _localStrategy(String id, String name) => StrategyData( + id: id, + name: name, + mapData: MapValue.ascent, + versionNumber: Settings.versionNumber, + lastEdited: DateTime.utc(2026), + folderID: null, + pages: const [], + ); diff --git a/test/rectangle_axis_resize_geometry_test.dart b/test/rectangle_axis_resize_geometry_test.dart index 422ba4ef..a34c46eb 100644 --- a/test/rectangle_axis_resize_geometry_test.dart +++ b/test/rectangle_axis_resize_geometry_test.dart @@ -13,7 +13,7 @@ import 'package:icarus/widgets/draggable_widgets/utilities/rectangle_axis_resize class _NoopStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( + return const StrategyState( isSaved: true, stratName: null, id: 'axis-resize-test', diff --git a/test/remote_editor_snapshot_provider_test.dart b/test/remote_editor_snapshot_provider_test.dart new file mode 100644 index 00000000..2e979961 --- /dev/null +++ b/test/remote_editor_snapshot_provider_test.dart @@ -0,0 +1,471 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + test('a refresh after a failed page selection resumes its live read', + () async { + final repository = _Repository(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + await remote.openStrategy('strategy', activePagePublicId: 'a'); + + // A teammate deletes page a; reading b, where the editor moves, fails. + repository.failPageReads = true; + repository.shells.add(_shell(['b'])); + await _settle(); + expect(remote.activePagePublicId, 'b'); + expect(repository.watchedPages, isNot(contains('b'))); + + repository.failPageReads = false; + // Two refreshes at once start one live read, not two. + await Future.wait([remote.refresh(), remote.refresh()]); + expect(repository.watchedPages.where((id) => id == 'b'), hasLength(1)); + expect( + container + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .revision, + 1, + ); + + // A teammate's later edit to b reaches the editor. + repository.pageStreams['b']!.add(_page('b', revision: 2)); + await _settle(); + expect( + container + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .revision, + 2, + ); + }); + + test('a restored page is read fresh with the shell and watched again', + () async { + final repository = _Repository(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + await remote.openStrategy('strategy', activePagePublicId: 'a'); + // A teammate deletes page a; the editor's live read moves to b. + repository.shells.add(_shell(['b'])); + await _settle(); + expect(remote.activePagePublicId, 'b'); + + // Restored on the server; the shell watched has not caught up yet. + await remote.showRestoredPage('a'); + + final snapshot = container.read(remoteEditorSnapshotProvider).valueOrNull; + expect(snapshot?.activePage?.page.publicId, 'a'); + expect(snapshot?.pages.map((page) => page.publicId), ['a', 'b']); + expect(repository.watchedPages.last, 'a'); + repository.pageStreams['a']!.add(_page('a', revision: 2)); + await _settle(); + expect( + container + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .revision, + 2, + ); + }); + + test('a read from before a restore finishing late changes nothing', () async { + final repository = _Repository(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + await remote.openStrategy('strategy', activePagePublicId: 'a'); + repository.shells.add(_shell(['b'], revision: 2)); + await _settle(); + + // Restoring bumped the strategy's revision. + repository.shellRevision = 3; + // A refresh starts while the page is still deleted... + final gate = Completer(); + repository.staleShellGate = gate; + final stale = remote.refresh(); + // ...and the page is restored and shown before it answers. + await remote.showRestoredPage('a'); + gate.complete(); + await stale; + // The watched shell's last push, from before the restore, arrives late. + repository.shells.add(_shell(['b'], revision: 2)); + await _settle(); + + final snapshot = container.read(remoteEditorSnapshotProvider).valueOrNull; + expect(snapshot?.activePage?.page.publicId, 'a'); + expect(snapshot?.pages.map((page) => page.publicId), ['a', 'b']); + expect(remote.activePagePublicId, 'a'); + }); + + test('a refresh never replaces a newer shell the live read brought', + () async { + final repository = _Repository(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + await remote.openStrategy('strategy', activePagePublicId: 'b'); + + // A refresh reads the shell from while a was deleted, then waits on b. + repository.shellRevision = 2; + repository.failPageReads = false; + final gate = Completer(); + repository.pageReadGate = gate; + final refresh = remote.refresh(); + await _settle(); + // Meanwhile a teammate restores a; the live read brings it. + repository.shells.add(_shell(['a', 'b'], revision: 3)); + await _settle(); + gate.complete(); + await refresh; + + final snapshot = container.read(remoteEditorSnapshotProvider).valueOrNull; + expect(snapshot?.header.revision, 3); + expect(snapshot?.pages.map((page) => page.publicId), ['a', 'b']); + }); + + test('a page read a restore overtook failing late changes nothing', () async { + final repository = _Repository(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_ReadyAuthProvider.new), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + await remote.openStrategy('strategy', activePagePublicId: 'a'); + + // A is deleted: the editor starts reading b, slowly, and it will fail. + final gate = Completer(); + repository.pageReadGate = gate; + repository.failNextRead.add('b'); + final selectB = remote.setActivePage('b'); + await _settle(); + // A is restored and shown before b's read answers. + await remote.showRestoredPage('a'); + gate.complete(); + await selectB; + + final state = container.read(remoteEditorSnapshotProvider); + expect(state.hasError, isFalse); + expect(state.valueOrNull?.activePage?.page.publicId, 'a'); + repository.pageStreams['a']!.add(_page('a', revision: 2)); + await _settle(); + expect( + container + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .revision, + 2, + ); + }); + + test('a signed-out reader on a share link reads through the link', () async { + final repository = _Repository(); + final auth = _SignedOutAuthProvider(); + final container = ProviderContainer(overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => auth), + strategyOpQueueProvider.overrideWith(_IdleOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_IdleMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue(null), + ]); + addTearDown(container.dispose); + container.read(shareLinkViewProvider.notifier).state = + (strategyPublicId: 'strategy', token: 'ICR-LINK'); + final remote = container.read(remoteEditorSnapshotProvider.notifier); + await container.read(remoteEditorSnapshotProvider.future); + + await remote.openStrategy('strategy'); + expect(repository.readTokens, isNotEmpty); + expect(repository.readTokens, everyElement('ICR-LINK')); + + // A refusal is the link's, not a broken session: there is no session, + // so no auth incident opens, and the editor sees the error itself. + repository.shellError = const ConvexFunctionException( + code: ConvexErrorCode.unauthenticated, + rawCode: 'UNAUTHENTICATED', + message: 'Unauthenticated', + ); + await remote.refresh(); + expect(auth.unauthenticatedReports, 0); + expect(container.read(remoteEditorSnapshotProvider).hasError, isTrue); + + repository.shellError = const ConvexFunctionException( + code: ConvexErrorCode.shareLinkRevoked, + rawCode: 'SHARE_LINK_REVOKED', + message: 'Share link revoked', + ); + await remote.refresh(); + expect(auth.unauthenticatedReports, 0); + expect( + isShareLinkRevokedError( + container.read(remoteEditorSnapshotProvider).error!, + ), + isTrue, + ); + + // A strategy opened any other way reads with the reader's own access. + repository.shellError = null; + repository.readTokens.clear(); + await remote.openStrategy('another-strategy'); + expect(repository.readTokens, everyElement(isNull)); + }); +} + +Future _settle() async { + for (var i = 0; i < 5; i += 1) { + await Future.delayed(Duration.zero); + } +} + +RemotePage _remotePage(String id, {int revision = 1}) => RemotePage( + publicId: id, + strategyPublicId: 'strategy', + name: id, + sortIndex: 0, + isAttack: true, + revision: revision, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ); + +RemoteStrategyShell _shell(List pageIds, {int revision = 1}) => + RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy', + name: 'Strategy', + mapData: 'ascent', + revision: revision, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + role: 'owner', + ), + pages: [for (final id in pageIds) _remotePage(id)], + ); + +RemotePageSnapshot _page(String id, {int revision = 1}) => RemotePageSnapshot( + page: _remotePage(id, revision: revision), + content: RemotePageContent( + revision: 1, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + ), + elements: const [], + lineups: const [], + assetsById: const {}, + ); + +class _Repository extends ConvexStrategyRepository { + _Repository() : super(IcarusConvexApi(_UnusedTransport())); + + final shells = StreamController.broadcast(); + final pageStreams = >{}; + final watchedPages = []; + + /// The share token each read carried, in order. + final readTokens = []; + bool failPageReads = false; + Object? shellError; + + /// While set, a shell read answers from before page a was restored, once + /// the gate opens. + Completer? staleShellGate; + + /// The revision of the shell reads answer. + int shellRevision = 1; + + /// While set, the next page read waits for it, then answers as usual. + Completer? pageReadGate; + + /// Pages whose next read fails. + final failNextRead = {}; + + @override + Future fetchShell( + String strategyPublicId, { + String? shareToken, + }) async { + readTokens.add(shareToken); + if (shellError case final error?) throw error; + final gate = staleShellGate; + if (gate != null) { + staleShellGate = null; + await gate.future; + return _shell(['b'], revision: 2); + } + return _shell(failPageReads ? ['b'] : ['a', 'b'], revision: shellRevision); + } + + @override + Stream watchShell( + String strategyPublicId, { + String? shareToken, + }) { + readTokens.add(shareToken); + return shells.stream; + } + + @override + Future fetchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + String? shareToken, + }) async { + readTokens.add(shareToken); + final gate = pageReadGate; + if (gate != null) { + pageReadGate = null; + await gate.future; + } + if (failPageReads || failNextRead.remove(pagePublicId)) { + throw StateError('offline'); + } + return _page(pagePublicId); + } + + @override + Stream watchPageSnapshot({ + required String strategyPublicId, + required String pagePublicId, + String? shareToken, + }) { + readTokens.add(shareToken); + watchedPages.add(pagePublicId); + return (pageStreams[pagePublicId] ??= + StreamController.broadcast()) + .stream; + } +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: const {}, + userMetadata: const {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _SignedOutAuthProvider extends AuthProvider { + int unauthenticatedReports = 0; + + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); + + @override + Future reportConvexUnauthenticated({ + required String source, + Object? error, + StackTrace? stackTrace, + }) async { + unauthenticatedReports += 1; + } +} + +class _IdleOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy', + clientId: 'client', + durableLoaded: true, + ); + + @override + void setActiveStrategy( + String? strategyPublicId, { + required String? accountId, + }) {} +} + +class _IdleMediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => + const CloudMediaUploadQueueState(jobs: [], isProcessing: false); +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} diff --git a/test/remote_page_merge_test.dart b/test/remote_page_merge_test.dart new file mode 100644 index 00000000..df2c7a58 --- /dev/null +++ b/test/remote_page_merge_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/strategy/remote_page_merge.dart'; + +LineUpOrigin _origin(String id, double x) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: 'agent-$id', + type: AgentType.sova, + position: Offset(x, 0), + lineUpID: id, + ), + ); + +LineUpLanding _landing(String id, double x) => LineUpLanding( + id: id, + ability: PlacedAbility( + id: 'ability-$id', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: Offset(x, 0), + lineUpID: id, + ), + ); + +LineUpLink _link(String id, String originId, String landingId, + [String? name]) => + LineUpLink( + id: id, + originId: originId, + landingId: landingId, + name: name ?? id, + ); + +void main() { + List merge( + List current, List incoming, Set held) => + mergeRemoteItems( + current: current, + incoming: incoming, + idOf: (id) => id.split(':').first, + keep: held.contains, + ); + + test('takes the server copy of everything not held, in server order', () { + expect(merge(['a:1', 'b:1'], ['b:2', 'c:1', 'a:2'], {}), + ['b:2', 'c:1', 'a:2']); + }); + + test('a held item keeps its screen copy', () { + expect(merge(['a:1', 'b:1'], ['a:2', 'b:2'], {'a'}), ['a:1', 'b:2']); + }); + + test('a held item the server removed stays in its place', () { + expect(merge(['a:1', 'b:1', 'c:1'], ['b:1', 'c:1'], {'a'}), + ['a:1', 'b:1', 'c:1']); + expect(merge(['a:1', 'b:1', 'c:1'], ['a:1', 'c:1'], {'b'}), + ['a:1', 'b:1', 'c:1']); + }); + + test('an unheld item the server removed goes', () { + expect(merge(['a:1', 'b:1'], ['b:1'], {'b'}), ['b:1']); + }); + + group('held lineups', () { + // On screen: k1 (o1 → shared) and k2 (o2 → shared) share a landing; k3 + // (o3 → l3) stands apart. The server moved every spot to 9 and renamed + // every lineup. + final local = LineUpGraph( + origins: [_origin('o1', 1), _origin('o2', 1), _origin('o3', 1)], + landings: [_landing('shared', 1), _landing('l3', 1)], + links: [ + _link('k1', 'o1', 'shared'), + _link('k2', 'o2', 'shared'), + _link('k3', 'o3', 'l3'), + ], + ); + final remote = LineUpGraph( + origins: [_origin('o1', 9), _origin('o2', 9), _origin('o3', 9)], + landings: [_landing('shared', 9), _landing('l3', 9)], + links: [ + _link('k1', 'o1', 'shared', 'k1 renamed'), + _link('k2', 'o2', 'shared', 'k2 renamed'), + _link('k3', 'o3', 'l3', 'k3 renamed'), + ], + ); + // The group rows: g1 holds k1 and k2 with their spots, g3 holds k3 (and + // a teammate's k4, when there is one). + const groups = { + 'k1': 'g1', + 'o1': 'g1', + 'k2': 'g1', + 'o2': 'g1', + 'shared': 'g1', + 'k3': 'g3', + 'o3': 'g3', + 'l3': 'g3', + 'k4': 'g3', + 'o4': 'g3', + }; + String? groupOf(String id) => groups[id]; + Map names(LineUpGraph graph) => + {for (final link in graph.links) link.id: link.name}; + Map spots(LineUpGraph graph) => { + for (final origin in graph.origins) + origin.id: origin.agent.position.dx, + for (final landing in graph.landings) + landing.id: landing.ability.position.dx, + }; + + test('nothing held takes the server copy whole', () { + final (graph, held) = mergeHeldLineups( + local: local, + remote: remote, + holding: {'x'}, + groupOf: groupOf, + ); + + expect(held, isEmpty); + expect(identical(graph, remote), isTrue); + }); + + test('holding a spot holds its whole group, and only that', () { + final (graph, held) = mergeHeldLineups( + local: local, + remote: remote, + holding: {'o1'}, + groupOf: groupOf, + ); + + // o1 holds group g1, so k2 waits with k1. + expect(held, {'g1'}); + expect(names(graph), {'k1': 'k1', 'k2': 'k2', 'k3': 'k3 renamed'}); + expect(spots(graph), { + 'o1': 1, + 'o2': 1, + 'o3': 9, + 'shared': 1, + 'l3': 9, + }); + // Held objects stay the ones on screen. + expect(identical(graph.origins.first, local.origins.first), isTrue); + }); + + test('a teammate lineup new on a held spot waits off the canvas', () { + final withNew = LineUpGraph( + origins: [...remote.origins, _origin('o4', 9)], + landings: remote.landings, + links: [...remote.links, _link('k4', 'o4', 'l3')], + ); + + final (graph, held) = mergeHeldLineups( + local: local, + remote: withNew, + holding: {'l3'}, + groupOf: groupOf, + ); + + expect(held, {'g3'}); + expect(graph.links.map((link) => link.id), ['k1', 'k2', 'k3']); + expect(graph.origins.map((origin) => origin.id), ['o1', 'o2', 'o3']); + expect(spots(graph)['l3'], 1); + }); + + test('a held lineup the server removed stays in its place', () { + final without = LineUpGraph( + origins: [remote.origins[1], remote.origins[2]], + landings: remote.landings, + links: [remote.links[1], remote.links[2]], + ); + + final (graph, held) = mergeHeldLineups( + local: local, + remote: without, + holding: {'k1'}, + groupOf: groupOf, + ); + + expect(held, {'g1'}); + expect(graph.links.map((link) => link.id), ['k1', 'k2', 'k3']); + expect(spots(graph)['o1'], 1); + }); + + test('a group holds lineups no spot connects any more', () { + // k3 is in g1 too: the lineup that joined it to k1 was deleted, and + // a group never splits. + String? oneGroup(String id) => id == 'x' ? null : 'g1'; + + final (graph, held) = mergeHeldLineups( + local: local, + remote: remote, + holding: {'k1'}, + groupOf: oneGroup, + ); + + expect(held, {'g1'}); + expect(names(graph), {'k1': 'k1', 'k2': 'k2', 'k3': 'k3'}); + }); + + test('a lineup in no group yet holds what shares its spots', () { + final (graph, held) = mergeHeldLineups( + local: local, + remote: remote, + holding: {'o1'}, + groupOf: (_) => null, + ); + + expect(held, isEmpty); + expect(names(graph), {'k1': 'k1', 'k2': 'k2', 'k3': 'k3 renamed'}); + }); + }); +} diff --git a/test/screenshot_export_failure_test.dart b/test/screenshot_export_failure_test.dart deleted file mode 100644 index 02b3c110..00000000 --- a/test/screenshot_export_failure_test.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:icarus/const/app_provider_container.dart'; -import 'package:icarus/const/coordinate_system.dart'; -import 'package:icarus/providers/strategy_provider.dart'; -import 'package:icarus/widgets/editor_toolbar.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; - -class _FailingSave extends StrategyProvider { - final pending = Completer(); - - @override - StrategyState build() => StrategyState( - isSaved: true, stratName: 'Test', id: 'test', storageDirectory: null); - - @override - Future forceSaveNow(String id) => pending.future; -} - -void main() { - testWidgets('failed screenshot preparation clears the spinner', - (tester) async { - CoordinateSystem(playAreaSize: const Size(1600, 900)); - final saver = _FailingSave(); - final container = ProviderContainer(overrides: [ - strategyProvider.overrideWith(() => saver), - ]); - appProviderContainer = container; - addTearDown(container.dispose); - await tester.pumpWidget(UncontrolledProviderScope( - container: container, - child: const ShadApp(home: Scaffold(body: EditorToolbar())))); - await tester.tap(find.byIcon(LucideIcons.camera200)); - await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - saver.pending.completeError(StateError('test save failure')); - await tester.pump(); - expect(find.byType(CircularProgressIndicator), findsNothing); - expect(find.byIcon(LucideIcons.camera200), findsOneWidget); - expect(tester.takeException(), isNull); - }); -} diff --git a/test/screenshot_view_test.dart b/test/screenshot_view_test.dart index 7331766b..1a66d3c2 100644 --- a/test/screenshot_view_test.dart +++ b/test/screenshot_view_test.dart @@ -16,6 +16,7 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/pen_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/providers/utility_provider.dart'; @@ -178,12 +179,12 @@ void main() { bool showUltOrbs = false, String? pageName, }) { - final strategyState = StrategyState( - isSaved: true, - stratName: 'test strategy', - id: 'strategy-id', + const strategyState = StrategyState( + strategyId: 'strategy-id', + strategyName: 'test strategy', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ); return ProviderScope( @@ -254,7 +255,7 @@ void main() { testWidgets('pre-hydrated screenshot providers render without build writes', (tester) async { - final strategyState = StrategyState( + const strategyState = StrategyState( isSaved: true, stratName: 'test strategy', id: 'strategy-id', diff --git a/test/share/share_link_copy_test.dart b/test/share/share_link_copy_test.dart new file mode 100644 index 00000000..2c094c89 --- /dev/null +++ b/test/share/share_link_copy_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/share/share_link_copy.dart'; + +void main() { + test('disable copy states the boundary without overpromising', () { + expect(ShareLinkCopy.disableAction, 'Disable link'); + expect(ShareLinkCopy.disableDescription, contains('prevents new people')); + expect(ShareLinkCopy.disableDescription, contains('keep their access')); + expect( + ShareLinkCopy.disableDescription, + contains('viewing through it without an account'), + ); + for (final targetType in ['strategy', 'folder']) { + for (final opensInBrowser in [true, false]) { + expect( + ShareLinkCopy.dialogDescription( + targetType, + opensInBrowser: opensInBrowser, + ), + contains('until you disable them'), + ); + } + } + // Only a strategy link that opens in a browser promises a view without + // an account; a desktop link hands off to an app the reader may lack. + expect( + ShareLinkCopy.dialogDescription('strategy', opensInBrowser: true), + contains('no account needed'), + ); + expect( + ShareLinkCopy.dialogDescription('strategy', opensInBrowser: false), + isNot(contains('no account')), + ); + expect( + ShareLinkCopy.dialogDescription('folder', opensInBrowser: true), + isNot(contains('no account')), + ); + }); +} diff --git a/test/share_link_format_test.dart b/test/share_link_format_test.dart new file mode 100644 index 00000000..00d1349c --- /dev/null +++ b/test/share_link_format_test.dart @@ -0,0 +1,298 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/share/share_link_format.dart'; + +void main() { + final production = icarusProductionShareOrigin; + final beta = Uri.parse('https://beta.icarusstrats.com'); + final pagesPreview = Uri.parse('https://abc123.icarus-web-a50.pages.dev'); + final localDev = Uri.parse('http://localhost:5000'); + + group('share link formatting', () { + test('builds user-facing icarusstrats.com share URLs', () { + expect( + buildIcarusShareLink('ICR-2345-6789-ABCD-EFGH', origin: production), + 'https://icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + ); + }); + + test('builds share URLs on the current web origin', () { + expect( + buildIcarusShareLink('ICR-2345-6789-ABCD-EFGH', origin: beta), + 'https://beta.icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + ); + expect( + buildIcarusShareLink('ICR-2345-6789-ABCD-EFGH', origin: pagesPreview), + 'https://abc123.icarus-web-a50.pages.dev/share/ICR-2345-6789-ABCD-EFGH', + ); + expect( + buildIcarusShareLink('ICR-2345-6789-ABCD-EFGH', origin: localDev), + 'http://localhost:5000/share/ICR-2345-6789-ABCD-EFGH', + ); + }); + + test('built links round-trip through the recognizer', () { + for (final origin in [production, beta, pagesPreview, localDev]) { + final link = buildIcarusShareLink( + 'ICR-2345-6789-ABCD-EFGH', + origin: origin, + ); + expect( + extractIcarusShareCode(link, currentOrigin: origin), + 'ICR-2345-6789-ABCD-EFGH', + reason: link, + ); + } + }); + + test('generates normalized share codes', () { + expect( + generateIcarusShareCode(random: Random(1)), + matches( + RegExp( + r'^ICR-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}-[2-9A-HJ-NP-Z]{4}$', + ), + ), + ); + }); + + test('extracts codes from icarusstrats.com URLs', () { + expect( + extractIcarusShareCode( + 'https://icarusstrats.com/share/icr-2345-6789-abcd-efgh', + currentOrigin: production, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + expect( + extractIcarusShareCode( + 'https://www.icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + currentOrigin: production, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + expect( + isIcarusShareUri( + Uri.parse('https://icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH'), + currentOrigin: production, + ), + isTrue, + ); + }); + + test('recognizes production links while running on another origin', () { + expect( + isIcarusShareUri( + Uri.parse('https://icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH'), + currentOrigin: beta, + ), + isTrue, + ); + expect( + extractIcarusShareCode( + 'https://www.icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + currentOrigin: pagesPreview, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + }); + + test('recognizes links on the current web origin', () { + expect( + isIcarusShareUri( + Uri.parse( + 'https://beta.icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + ), + currentOrigin: beta, + ), + isTrue, + ); + expect( + extractIcarusShareCode( + 'https://BETA.icarusstrats.com/share/icr-2345-6789-abcd-efgh', + currentOrigin: beta, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + expect( + extractIcarusShareCode( + 'https://abc123.icarus-web-a50.pages.dev/share/ICR-2345-6789-ABCD-EFGH', + currentOrigin: pagesPreview, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + }); + + test('does not trust another origin just because the web app can', () { + // A native build (production origin) must not accept beta links, and + // a beta page must not accept a different preview's links. + expect( + isIcarusShareUri( + Uri.parse( + 'https://beta.icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH', + ), + currentOrigin: production, + ), + isFalse, + ); + expect( + isIcarusShareUri( + Uri.parse( + 'https://other.icarus-web-a50.pages.dev/share/ICR-2345-6789-ABCD-EFGH', + ), + currentOrigin: beta, + ), + isFalse, + ); + expect( + isIcarusShareUri( + Uri.parse('http://beta.icarusstrats.com/share/ICR-2345-6789-ABCD'), + currentOrigin: beta, + ), + isFalse, + reason: 'scheme is part of the origin', + ); + expect( + isIcarusShareUri( + Uri.parse('http://localhost:6000/share/ICR-2345-6789-ABCD-EFGH'), + currentOrigin: localDev, + ), + isFalse, + reason: 'port is part of the origin', + ); + }); + + test('the web page root is not a share link', () { + expect( + isIcarusShareUri( + Uri.parse('https://beta.icarusstrats.com/'), + currentOrigin: beta, + ), + isFalse, + ); + expect( + isIcarusShareUri( + Uri.parse('https://beta.icarusstrats.com/?code=abc'), + currentOrigin: beta, + ), + isFalse, + reason: 'an auth callback carries ?code= but is not a share link', + ); + }); + + test('extracts codes from custom scheme links', () { + expect( + extractIcarusShareCode( + 'icarus://share?code=icr-2345-6789-abcd-efgh', + currentOrigin: production, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + expect( + extractIcarusShareCode( + 'icarus://share?code=icr-2345-6789-abcd-efgh', + currentOrigin: beta, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + }); + + test('keeps legacy UUID tokens unchanged', () { + const legacyToken = '0ee927ca-babc-4350-9b6f-02cfa833b14b'; + expect( + extractIcarusShareCode( + 'icarus://share?token=$legacyToken', + currentOrigin: production, + ), + legacyToken, + ); + expect( + extractIcarusShareCode( + 'https://icarusstrats.com/share/$legacyToken', + currentOrigin: production, + ), + legacyToken, + ); + }); + + test('rejects unrelated URLs', () { + expect( + extractIcarusShareCode( + 'https://example.com/share/ICR-2345-6789-ABCD-EFGH', + currentOrigin: beta, + ), + isNull, + ); + expect( + isIcarusShareUri( + Uri.parse('https://example.com/share/ICR-2345-6789-ABCD-EFGH'), + currentOrigin: beta, + ), + isFalse, + ); + expect( + isIcarusShareUri( + Uri.parse('https://icarusstrats.com.evil.test/share/ICR-2345'), + currentOrigin: production, + ), + isFalse, + ); + expect( + isIcarusShareUri( + Uri.parse('ftp://icarusstrats.com/share/ICR-2345-6789-ABCD-EFGH'), + currentOrigin: production, + ), + isFalse, + ); + }); + + test('links that do not decode are rejected, not thrown', () { + expect( + isIcarusShareUri( + Uri.parse('https://beta.icarusstrats.com/share/%FF'), + currentOrigin: beta, + ), + isFalse, + ); + expect( + extractIcarusShareCode( + 'https://beta.icarusstrats.com/share/%FF', + currentOrigin: beta, + ), + isNull, + ); + expect( + extractIcarusShareCode( + 'https://beta.icarusstrats.com/share?code=%E0%A4%A', + currentOrigin: beta, + ), + isNull, + ); + }); + + test('rejects garbage input', () { + expect(extractIcarusShareCode('', currentOrigin: beta), isNull); + expect(extractIcarusShareCode(' ', currentOrigin: beta), isNull); + expect( + isIcarusShareUri(Uri.parse('share'), currentOrigin: beta), + isFalse, + reason: 'a relative path has no origin', + ); + expect( + isIcarusShareUri(Uri(), currentOrigin: beta), + isFalse, + ); + }); + + test('accepts standalone share codes', () { + expect( + extractIcarusShareCode( + 'icr-2345-6789-abcd-efgh', + currentOrigin: production, + ), + 'ICR-2345-6789-ABCD-EFGH', + ); + }); + }); +} diff --git a/test/share_redemption_mapping_test.dart b/test/share_redemption_mapping_test.dart new file mode 100644 index 00000000..32d2d0cb --- /dev/null +++ b/test/share_redemption_mapping_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; + +void main() { + ConvexObject strategyResult({required String role, bool? alreadyHadAccess}) { + return ConvexObject({ + 'ok': const ConvexBoolean(true), + 'targetType': const ConvexString('strategy'), + 'strategyPublicId': const ConvexString('strategy-1'), + 'folderPublicId': const ConvexNull(), + 'role': ConvexString(role), + if (alreadyHadAccess != null) + 'alreadyHadAccess': ConvexBoolean(alreadyHadAccess), + }); + } + + Future redeem(ConvexObject result) async { + final repository = ConvexStrategyRepository( + IcarusConvexApi(_FakeTransport(result)), + ); + return (await repository.redeemShareLink('ICR-2345-6789-ABCD-EFGH')) + .alreadyHadAccess; + } + + test('uses what the server reports', () async { + expect(await redeem(strategyResult(role: 'viewer', alreadyHadAccess: true)), + isTrue); + expect( + await redeem(strategyResult(role: 'viewer', alreadyHadAccess: false)), + isFalse); + }); + + test('from a deployment that predates the field, an owner had access', + () async { + expect(await redeem(strategyResult(role: 'owner')), isTrue); + expect(await redeem(strategyResult(role: 'viewer')), isFalse); + }); +} + +final class _FakeTransport implements ConvexTransport { + _FakeTransport(this.result); + + final ConvexValue result; + + @override + Future mutation(String name, ConvexObject args) async => result; + + @override + dynamic noSuchMethod(Invocation invocation) => throw UnimplementedError(); +} diff --git a/test/side_switch_media_test.dart b/test/side_switch_media_test.dart new file mode 100644 index 00000000..be207f74 --- /dev/null +++ b/test/side_switch_media_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/placed_media_dimensions.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + group('PlacedText side projection', () { + test('empty text projection is reversible without mutating storage', () { + final text = _placedText(text: ''); + + _expectTextProjectionRoundTrip(text); + }); + + test('single-line projection is reversible without mutating storage', () { + final text = _placedText(text: 'one line'); + + _expectTextProjectionRoundTrip(text); + }); + + test('wrapped projection is reversible without mutating storage', () { + final text = _placedText( + text: 'this text is long enough to wrap across multiple lines', + size: 90, + ); + + _expectTextProjectionRoundTrip(text); + }); + }); + + test('PlacedImage projection is reversible without mutating storage', () { + final image = PlacedImage( + id: 'image-1', + position: const Offset(200, 220), + aspectRatio: 16 / 9, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + sizeVersion: worldSizedMediaVersion, + ); + + final coordinateSystem = CoordinateSystem.instance; + final canonicalScreen = coordinateSystem.coordinateToScreen(image.position); + final size = PlacedImageDimensions.screenSize( + coordinateSystem: coordinateSystem, + scale: image.scale, + aspectRatio: image.aspectRatio, + ); + final defense = coordinateSystem.screenPositionForSide( + attackScreenPosition: canonicalScreen, + reflectionOffset: Offset(size.width, size.height), + isAttack: false, + ); + final recovered = coordinateSystem.screenPositionFromSide( + sideScreenPosition: defense, + reflectionOffset: Offset(size.width, size.height), + isAttack: false, + ); + + _expectClose(recovered, canonicalScreen); + _expectClose(image.position, const Offset(200, 220)); + }); +} + +PlacedText _placedText({ + required String text, + double size = 220, +}) { + return PlacedText( + id: 'text-1', + position: const Offset(100, 120), + size: size, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = text; +} + +void _expectTextProjectionRoundTrip(PlacedText text) { + final coordinateSystem = CoordinateSystem.instance; + final canonicalScreen = coordinateSystem.coordinateToScreen(text.position); + final size = PlacedTextDimensions.screenSize( + coordinateSystem: coordinateSystem, + widthWorld: text.size, + fontSizeWorld: text.fontSize, + text: text.text, + ); + final defense = coordinateSystem.screenPositionForSide( + attackScreenPosition: canonicalScreen, + reflectionOffset: Offset(size.width, size.height), + isAttack: false, + ); + final recovered = coordinateSystem.screenPositionFromSide( + sideScreenPosition: defense, + reflectionOffset: Offset(size.width, size.height), + isAttack: false, + ); + + _expectClose(recovered, canonicalScreen); + _expectClose(text.position, const Offset(100, 120)); +} + +void _expectClose(Offset actual, Offset expected) { + expect(actual.dx, closeTo(expected.dx, 1e-9)); + expect(actual.dy, closeTo(expected.dy, 1e-9)); +} diff --git a/test/sort_index_tie_order_test.dart b/test/sort_index_tie_order_test.dart new file mode 100644 index 00000000..0fd0653c --- /dev/null +++ b/test/sort_index_tie_order_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; + +// Legacy pages carry duplicate sortIndexes, and two clients adding at once can +// both pick max+1. The server returns tied rows in creation order, so every +// client must keep that order; otherwise tied items flip stacking between two +// loads of the same data. +void main() { + // Enough rows that Dart's List.sort leaves insertion sort for quicksort. + const count = 60; + int tiedSortIndex(int i) => i % 3; + + List expectedTieOrder() { + final ids = [for (var i = 0; i < count; i++) 'item-$i']; + return [ + for (var group = 0; group < 3; group++) + ...ids + .where((id) => tiedSortIndex(int.parse(id.split('-')[1])) == group), + ]; + } + + test('elements keep the server order within sortIndex ties', () { + final elements = [ + for (var i = 0; i < count; i++) + RemoteElement( + publicId: 'item-$i', + strategyPublicId: 'strategy', + pagePublicId: 'page', + elementType: 'agent', + payload: const {}, + sortIndex: tiedSortIndex(i), + revision: 1, + deleted: false, + ), + ]; + + final grouped = RemoteFullStrategySnapshot.groupElementsByPage(elements); + + expect( + grouped['page']!.map((element) => element.publicId).toList(), + expectedTieOrder(), + ); + }); + + test('lineups keep the server order within sortIndex ties', () { + final lineups = [ + for (var i = 0; i < count; i++) + RemoteLineup( + publicId: 'item-$i', + strategyPublicId: 'strategy', + pagePublicId: 'page', + payload: const {}, + sortIndex: tiedSortIndex(i), + revision: 1, + deleted: false, + ), + ]; + + final grouped = RemoteFullStrategySnapshot.groupLineupsByPage(lineups); + + expect( + grouped['page']!.map((lineup) => lineup.publicId).toList(), + expectedTieOrder(), + ); + }); +} diff --git a/test/strategy_cloud_migration_test.dart b/test/strategy_cloud_migration_test.dart new file mode 100644 index 00000000..ec5ca212 --- /dev/null +++ b/test/strategy_cloud_migration_test.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_cloud_migration.dart'; + +LineUpOrigin _origin(String id, Offset position) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: 'agent-$id', + type: AgentType.sova, + position: position, + lineUpID: id, + ), + ); + +LineUpLanding _landing(String id) => LineUpLanding( + id: id, + ability: PlacedAbility( + id: 'ability-$id', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: const Offset(30, 40), + lineUpID: id, + ), + ); + +StrategyPage _page(String pageId, LineUpGraph graph) { + return StrategyPage( + id: pageId, + name: pageId, + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + lineUpOrigins: graph.origins, + lineUpLandings: graph.landings, + lineUpLinks: graph.links, + ); +} + +/// One lineup whose landing shares its link's id, as the editor made them +/// before the graph synced natively. +LineUpGraph _sharedIdLineup() => LineUpGraph( + origins: [_origin('origin-1', const Offset(10, 20))], + landings: [_landing('link-1')], + links: [ + LineUpLink(id: 'link-1', originId: 'origin-1', landingId: 'link-1'), + ], + ); + +List _upload( + LineUpGraph graph, { + Set? usedLineupIds, +}) { + final ops = []; + appendMigratedPageOps( + ops, + _page('page-1', graph), + usedElementIds: {}, + usedLineupIds: usedLineupIds ?? {}, + ); + return ops.whereType().toList(); +} + +/// What a client hydrates from the uploaded rows, JSON round-tripped as the +/// server returns them. +LineUpGraph _hydrate(List adds) => _hydrated(adds).graph; + +/// [_hydrate], with the group each lineup and spot was drawn from. +CloudLineups _hydrated(List adds) { + return lineUpGraphFromCloudRows([ + for (final add in adds) + CloudLineupRow( + publicId: add.lineupPublicId, + payload: jsonDecode(jsonEncode(add.payload)) as Map, + ), + ]); +} + +String _canonical(Object? value) => + canonicalCloudJsonEncode(jsonDecode(jsonEncode(value))); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('a lineup is uploaded as a group row holding it and its spots', () { + final adds = _upload(_sharedIdLineup()); + + expect(adds.map((add) => add.lineupPublicId), ['link-1']); + expect(adds.single.payload['kind'], cloudLineupsPayloadKind); + expect(adds.single.sortIndex, 0); + final data = cloudPayloadData(adds.single.payload); + expect((data['origins'] as List).single['id'], 'origin-1'); + expect((data['landings'] as List).single['id'], 'link-1'); + expect((data['links'] as List).single['id'], 'link-1'); + // Hydrating the upload gives back the page's graph, ids and all, so the + // page never authors a patch for a lineup nobody touched. + expect( + _canonical(_hydrate(adds).toJson()), + _canonical(_sharedIdLineup().toJson()), + ); + }); + + test('a group id another page already took is not reused', () { + final usedLineupIds = {'link-1'}; + final adds = _upload( + _sharedIdLineup(), + // A page duplicated from page 1 already uploaded this group. + usedLineupIds: usedLineupIds, + ); + + final newId = adds.single.lineupPublicId; + expect(newId, isNot('link-1')); + expect(usedLineupIds, {'link-1', newId}); + final data = cloudPayloadData(adds.single.payload); + expect(data['id'], newId); + // Ids inside a row only need to be unique in the row, so they stay. + expect((data['links'] as List).single['id'], 'link-1'); + expect((data['origins'] as List).single['id'], 'origin-1'); + + // And the rows reproduce exactly when the hydrated graph is sent again. + final hydrated = _hydrated(adds); + final resent = cloudLineupRows(hydrated.graph, groupOf: hydrated.groupOf); + expect( + _canonical([for (final row in resent.rows) row.payload]), + _canonical([for (final add in adds) add.payload]), + ); + }); + + test('fan-in and fan-out upload with their shared spots and names', () { + final fanIn = LineUpGraph( + origins: [ + _origin('origin-a', const Offset(10, 20)), + _origin('origin-b', const Offset(50, 60)), + ], + landings: [_landing('shared'), _landing('other')], + links: [ + LineUpLink( + id: 'link-a', + originId: 'origin-a', + landingId: 'shared', + name: 'From heaven', + images: [SimpleImageData(id: 'image-a', fileExtension: '.png')], + ), + LineUpLink( + id: 'link-b', + originId: 'origin-b', + landingId: 'shared', + name: 'From mid', + ), + LineUpLink( + id: 'link-c', + originId: 'origin-a', + landingId: 'other', + name: 'From heaven, long', + ), + ], + ); + + final adds = _upload(fanIn); + + // Connected through their spots, the three lineups are one group. + expect(adds.map((add) => add.lineupPublicId), ['link-a']); + final hydrated = _hydrate(adds); + expect(hydrated.landings.map((landing) => landing.id), [ + 'shared', + 'other', + ]); + expect( + [ + for (final link in hydrated.links) + (link.originId, link.landingId, link.name), + ], + [ + ('origin-a', 'shared', 'From heaven'), + ('origin-b', 'shared', 'From mid'), + ('origin-a', 'other', 'From heaven, long'), + ], + ); + expect(hydrated.links.first.images.single.id, 'image-a'); + expect(_canonical(hydrated.toJson()), _canonical(fanIn.toJson())); + }); +} diff --git a/test/strategy_edit_access_test.dart b/test/strategy_edit_access_test.dart new file mode 100644 index 00000000..8e5b818f --- /dev/null +++ b/test/strategy_edit_access_test.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/shortcut_info.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/global_shortcuts.dart'; +import 'package:icarus/widgets/strategy_edit_boundary.dart'; + +class _RoleSnapshotNotifier extends RemoteEditorSnapshotNotifier { + _RoleSnapshotNotifier(this.role); + + final String role; + + @override + Future build() async { + final now = DateTime.utc(2026); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'cloud-strategy', + name: 'Cloud Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + role: role, + ), + pages: const [], + ), + activePage: null, + ); + } +} + +class _RecordingStrategyOpQueue extends StrategyOpQueueNotifier { + final enqueued = []; + + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'test-client', + durableLoaded: true, + ); + + @override + Future enqueueAll( + Iterable ops, { + bool flushImmediately = false, + }) async { + enqueued.addAll(ops); + } +} + +Future _invokeAddTextShortcut( + WidgetTester tester, + StrategyCapabilities capabilities, +) async { + late WidgetRef widgetRef; + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentStrategyCapabilitiesProvider.overrideWithValue(capabilities), + ], + child: MaterialApp( + home: GlobalShortcuts( + child: Consumer( + builder: (context, ref, _) { + widgetRef = ref; + return const SizedBox( + key: ValueKey('shortcut-target'), + width: 100, + height: 100, + ); + }, + ), + ), + ), + ), + ); + + Actions.invoke( + tester.element(find.byKey(const ValueKey('shortcut-target'))), + const AddedTextIntent(), + ); + await tester.pump(); + + expect( + widgetRef.read(textProvider).length, + capabilities.canEditPages ? 1 : 0, + ); +} + +void main() { + testWidgets('viewers cannot add text with an editor shortcut', + (tester) async { + await _invokeAddTextShortcut( + tester, + StrategyCapabilities.fromCloudRole('viewer'), + ); + }); + + for (final role in ['editor', 'owner']) { + testWidgets('$role can add text with an editor shortcut', (tester) async { + await _invokeAddTextShortcut( + tester, + StrategyCapabilities.fromCloudRole(role), + ); + }); + } + + testWidgets('view-only edit controls absorb pointer input', (tester) async { + var taps = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole('viewer'), + ), + ], + child: MaterialApp( + home: StrategyEditBoundary( + disabledOpacity: 0.55, + child: GestureDetector( + key: const ValueKey('edit-control'), + onTap: () => taps += 1, + child: const SizedBox(width: 100, height: 100), + ), + ), + ), + ), + ); + + await tester.tap( + find.byKey(const ValueKey('edit-control')), + warnIfMissed: false, + ); + expect(taps, 0); + expect( + tester + .widget( + find.descendant( + of: find.byType(StrategyEditBoundary), + matching: find.byType(ExcludeFocus), + ), + ) + .excluding, + isTrue, + ); + expect( + tester + .widget( + find.descendant( + of: find.byType(StrategyEditBoundary), + matching: find.byType(Opacity), + ), + ) + .opacity, + 0.55, + ); + }); + + test('a stale cloud role cannot grant access to another strategy', () async { + final container = ProviderContainer( + overrides: [ + remoteEditorSnapshotProvider.overrideWith( + () => _RoleSnapshotNotifier('editor'), + ), + ], + ); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'different-cloud-strategy', + strategyName: 'Different Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + ); + + expect( + container.read(currentStrategyCapabilitiesProvider).canEditPages, + isFalse, + ); + }); + + for (final role in ['viewer', 'editor', 'owner']) { + test('$role cloud op queue access matches page edit capability', () async { + final queue = _RecordingStrategyOpQueue(); + final container = ProviderContainer( + overrides: [ + remoteEditorSnapshotProvider.overrideWith( + () => _RoleSnapshotNotifier(role), + ), + strategyOpQueueProvider.overrideWith(() => queue), + ], + ); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + container.read(strategyProvider.notifier).setFromState( + const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + ); + + await container.read(strategyProvider.notifier).enqueueOps( + const [ + StrategyPatchOp( + opId: 'strategy-op', + payload: {'name': 'Changed'}, + expectedStrategyRevision: 1, + ), + ], + ); + + expect( + queue.enqueued.length, + role == 'viewer' ? 0 : 1, + ); + }); + } +} diff --git a/test/strategy_folder_import_test.dart b/test/strategy_folder_import_test.dart index 253d8be8..75122d06 100644 --- a/test/strategy_folder_import_test.dart +++ b/test/strategy_folder_import_test.dart @@ -17,8 +17,9 @@ import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/favorite_agents_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; -import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/services/archive_manifest.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; import 'package:path/path.dart' as path; bool _adaptersRegistered = false; @@ -76,7 +77,7 @@ void main() { await _writeStrategyFile(File(path.join(childDir.path, 'a-site.ica'))); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -105,7 +106,7 @@ void main() { await Directory(path.join(emptyChild.path, 'Deep Empty')).create(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -142,7 +143,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -182,7 +183,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -225,7 +226,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -279,7 +280,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -332,8 +333,7 @@ void main() { await _storeStrategy(name: 'default', folderID: rootFolder.id); await _storeStrategy(name: 'a-site', folderID: childFolder.id); - final exportDirectory = await container - .read(strategyProvider.notifier) + final exportDirectory = await StrategyImportExportService(container) .buildFolderExportDirectoryForTest(rootFolder.id); try { @@ -368,7 +368,7 @@ void main() { await Hive.box(HiveBoxNames.foldersBox).clear(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -433,8 +433,7 @@ void main() { themeProfileId: customProfile.id, ); - final exportDirectory = await container - .read(strategyProvider.notifier) + final exportDirectory = await StrategyImportExportService(container) .buildLibraryExportDirectoryForTest(); try { @@ -458,7 +457,7 @@ void main() { await MapThemeProfilesProvider.bootstrap(); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(zipFile.path)], ); @@ -499,6 +498,69 @@ void main() { } }); + test('library backup without custom colors preserves current color library', + () async { + await container + .read(appPreferencesProvider.notifier) + .setCustomColorValues(const [0xFF22C55E, 0xFF38BDF8]); + await _storeStrategy( + name: 'Legacy Backup', + folderID: null, + ); + + final exportDirectory = await StrategyImportExportService(container) + .buildLibraryExportDirectoryForTest(); + + try { + final rootDirectory = Directory( + path.join(exportDirectory.path, libraryBackupRootDirectoryName), + ); + final manifestFile = + File(path.join(rootDirectory.path, archiveMetadataFileName)); + final decoded = + jsonDecode(await manifestFile.readAsString()) as Map; + final globals = Map.from( + decoded['globals'] as Map); + final appPreferences = Map.from( + globals['appPreferences'] as Map); + appPreferences.remove('customColorValues'); + globals['appPreferences'] = appPreferences; + decoded['globals'] = globals; + await manifestFile.writeAsString(jsonEncode(decoded)); + + final zipFile = await _zipDirectory( + sourceDirectory: rootDirectory, + zipPath: path.join(tempDir.path, 'legacy-library-backup.zip'), + ); + + await Hive.box(HiveBoxNames.strategiesBox).clear(); + await Hive.box(HiveBoxNames.foldersBox).clear(); + await Hive.box(HiveBoxNames.mapThemeProfilesBox).clear(); + await MapThemeProfilesProvider.bootstrap(); + await container + .read(appPreferencesProvider.notifier) + .setCustomColorValues(const [0xFFABCDEF, 0xFF123456]); + + final result = + await StrategyImportExportService(container).loadFromFileDrop( + [XFile(zipFile.path)], + ); + + expect(result.globalStateRestored, isTrue); + expect(result.issues, isEmpty); + expect( + Hive.box(HiveBoxNames.appPreferencesBox) + .get(MapThemeProfilesProvider.appPreferencesSingletonKey) + ?.customColorValues, + const [0xFFABCDEF, 0xFF123456], + ); + } finally { + if (await exportDirectory.exists()) { + await exportDirectory.delete(recursive: true); + } + } + }); + test('batch import skips newer-version strategies and keeps valid siblings', () async { final sourceRoot = @@ -511,7 +573,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -544,7 +606,7 @@ void main() { await _writeStrategyFile(file); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(file.path)], ); @@ -580,7 +642,7 @@ void main() { ); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -610,7 +672,7 @@ void main() { await nestedText.writeAsString('notes'); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(sourceRoot.path)], ); @@ -631,7 +693,7 @@ void main() { await file.writeAsString('not a strategy'); final result = - await container.read(strategyProvider.notifier).loadFromFileDrop( + await StrategyImportExportService(container).loadFromFileDrop( [XFile(file.path)], ); diff --git a/test/strategy_image_source_test.dart b/test/strategy_image_source_test.dart new file mode 100644 index 00000000..55373476 --- /dev/null +++ b/test/strategy_image_source_test.dart @@ -0,0 +1,775 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/providers/collab/cloud_media_cache_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/local_image_file_native.dart' as native; +import 'package:icarus/services/local_image_file_stub.dart' as web; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/strategy/line_up_media_page.dart'; +import 'package:icarus/widgets/draggable_widgets/image/image_widget.dart'; +import 'package:path/path.dart' as path; +import 'package:shadcn_ui/shadcn_ui.dart'; + +const _imageId = 'image-1'; +const _remoteUrl = 'https://media.example.com/image-1.png'; +// A 1x1 PNG, still uploading from this browser. +final _pendingPng = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', +); + +RemoteImageAsset _asset({String? url = _remoteUrl, String status = 'active'}) { + return RemoteImageAsset( + publicId: _imageId, + fileExtension: '.png', + width: 16, + height: 9, + url: url, + legacyStoragePath: null, + uploadStatus: status, + ); +} + +class _FixedStrategy extends StrategyProvider { + _FixedStrategy({required this.source, required this.storageDirectory}); + + final StrategySource source; + final String? storageDirectory; + + @override + StrategyState build() => StrategyState( + strategyId: 'strategy-1', + strategyName: 'Strategy', + source: source, + storageDirectory: storageDirectory, + isOpen: true, + ); +} + +class _FixedSnapshot extends RemoteEditorSnapshotNotifier { + _FixedSnapshot(this.assets); + + final List assets; + + @override + Future build() async { + final now = DateTime.utc(2026); + final page = RemotePage( + publicId: 'page-1', + strategyPublicId: 'strategy-1', + name: 'Page 1', + sortIndex: 0, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy-1', + name: 'Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + role: 'owner', + ), + pages: [page], + ), + activePage: RemotePageSnapshot( + page: page, + content: RemotePageContent(revision: 1, createdAt: now, updatedAt: now), + elements: const [], + lineups: const [], + assetsById: {for (final asset in assets) asset.publicId: asset}, + ), + ); + } +} + +class _FixedUploadQueue extends CloudMediaUploadQueueNotifier { + _FixedUploadQueue(this.jobs, {this.reliable = true}); + + final List jobs; + + /// False stands for an outbox with records it could not read. + final bool reliable; + + @override + CloudMediaUploadQueueState build() => CloudMediaUploadQueueState( + jobs: jobs, + isProcessing: false, + durabilityError: reliable ? null : 'unreadable record', + ); +} + +CloudMediaUploadJob _queuedUpload() => CloudMediaUploadJob( + jobId: 'job-1', + accountId: 'account-a', + strategyPublicId: 'strategy-1', + assetPublicId: _imageId, + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026), + ); + +List _overrides({ + required StrategySource source, + required String? storageDirectory, + List assets = const [], + Map pendingBytes = const {}, + List uploads = const [], + String? accountId = 'account-a', + bool outboxReliable = true, +}) { + return [ + cloudMediaUploadQueueProvider.overrideWith( + () => _FixedUploadQueue(uploads, reliable: outboxReliable), + ), + cloudMediaAccountIdProvider.overrideWithValue(accountId), + strategyProvider.overrideWith( + () => _FixedStrategy(source: source, storageDirectory: storageDirectory), + ), + remoteEditorSnapshotProvider.overrideWith(() => _FixedSnapshot(assets)), + // Pending bytes only exist on web: no image files, signed in. + if (pendingBytes.isNotEmpty) ...[ + imageFilesOnDeviceProvider.overrideWithValue(false), + pendingMediaBytesStoreProvider.overrideWithValue( + MemoryPendingMediaBytesStore([ + for (final MapEntry(key: id, value: bytes) in pendingBytes.entries) + PendingMediaRecord( + key: ( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + assetPublicId: id, + ), + bytes: bytes, + savedAt: DateTime.now(), + ), + ]), + ), + ], + ]; +} + +/// Bumping [rebuild] rebuilds the mounted image, as a canvas redraw would. +Widget _imageApp({ + required StrategySource source, + required String? storageDirectory, + List assets = const [], + Map pendingBytes = const {}, + List uploads = const [], + String? accountId = 'account-a', + bool outboxReliable = true, + ValueNotifier? rebuild, +}) { + return ProviderScope( + overrides: _overrides( + source: source, + storageDirectory: storageDirectory, + assets: assets, + pendingBytes: pendingBytes, + uploads: uploads, + accountId: accountId, + outboxReliable: outboxReliable, + ), + child: MaterialApp( + home: Scaffold( + body: ValueListenableBuilder( + valueListenable: rebuild ?? ValueNotifier(0), + // Not const: a const image would be skipped on rebuild. + // ignore: prefer_const_constructors + builder: (context, _, __) => Stack( + // ignore: prefer_const_literals_to_create_immutables + children: [ + // ignore: prefer_const_constructors + ImageWidget( + id: _imageId, + aspectRatio: 16 / 9, + scale: 320, + fileExtension: '.png', + ), + ], + ), + ), + ), + ), + ); +} + +Future _storageWithImage(WidgetTester tester) async { + final storage = await tester.runAsync(() async { + final dir = await Directory.systemTemp.createTemp('icarus_image_widget'); + await _writeImage(dir); + return dir; + }); + addTearDown(() => storage!.deleteSync(recursive: true)); + return storage!; +} + +File _imageFile(Directory storage) => + File(path.join(storage.path, 'images', '$_imageId.png')); + +/// A real 1x1 PNG, so the file image decodes when IO is allowed to run. +Future _writeImage(Directory storage) async { + final file = _imageFile(storage); + await file.parent.create(recursive: true); + await file.writeAsBytes(base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + )); +} + +ImageProvider _paintedImage(WidgetTester tester) { + return tester.widget(find.byType(Image)).image; +} + +void main() { + group('resolveStrategyImageSource', () { + test('a file on this device wins over the cloud URL', () { + final source = resolveStrategyImageSource( + localFilePath: '/images/image-1.png', + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(), + ); + expect(source, isA()); + }); + + test('a cloud image without a file paints from its URL', () { + final source = resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(), + ); + expect((source as RemoteImageUrl).url, _remoteUrl); + }); + + test('a cloud image without a URL yet is loading', () { + // The page listing its asset has not arrived. + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: false, + uploadMayBeQueuedHere: false, + remoteAsset: null, + ), + isA(), + ); + // This device has its upload queued but not started. + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: true, + remoteAsset: null, + ), + isA(), + ); + // Its upload is under way somewhere. + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(url: null, status: 'pending'), + ), + isA(), + ); + }); + + test('a file on this device and the cloud URL both beat pending bytes', () { + expect( + resolveStrategyImageSource( + localFilePath: '/images/image-1.png', + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: null, + pendingBytes: _pendingPng, + ), + isA(), + ); + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(), + pendingBytes: _pendingPng, + ), + isA(), + ); + }); + + test('bytes still uploading paint until the cloud URL arrives', () { + final uploading = resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(url: null, status: 'pending'), + pendingBytes: _pendingPng, + ); + expect((uploading as ImageBytes).bytes, _pendingPng); + expect(uploading.imageProvider, isA()); + + // A failed attempt is retried from the same bytes; keep showing them. + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(url: null, status: 'failed'), + pendingBytes: _pendingPng, + ), + isA(), + ); + + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(), + pendingBytes: _pendingPng, + ), + isA(), + ); + }); + + test('a cloud image the loaded page has no asset for is unavailable', () { + // Its asset was deleted or never existed, and nothing is uploading it: + // no spinner that never ends. + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: null, + ), + isA(), + ); + }); + + test('a failed upload or a missing local file has failed', () { + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: true, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: _asset(url: null, status: 'failed'), + ), + isA(), + ); + expect( + resolveStrategyImageSource( + localFilePath: null, + isCloudStrategy: false, + assetsLoaded: true, + uploadMayBeQueuedHere: false, + remoteAsset: null, + ), + isA(), + ); + }); + }); + + group('local image files', () { + late Directory storage; + + setUp(() { + storage = Directory.systemTemp.createTempSync('icarus_image_source'); + File(path.join(storage.path, 'images', '$_imageId.png')) + ..createSync(recursive: true) + ..writeAsBytesSync(const [0]); + }); + + tearDown(() => storage.deleteSync(recursive: true)); + + test('desktop finds the file under the storage directory', () { + expect( + native.findLocalImageFile( + storageDirectory: storage.path, + imageId: _imageId, + fileExtension: '.png', + ), + path.join(storage.path, 'images', '$_imageId.png'), + ); + }); + + test('web never finds a file, even where one exists', () { + expect(web.deviceHasImageFiles, isFalse); + expect( + web.findLocalImageFile( + storageDirectory: storage.path, + imageId: _imageId, + fileExtension: '.png', + ), + isNull, + ); + }); + }); + + group('ImageWidget', () { + setUp(() { + CoordinateSystem(playAreaSize: const Size(1920, 1080)); + }); + + testWidgets( + 'a cloud image with no storage directory paints from its URL, ' + 'as on web', (tester) async { + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + assets: [_asset()], + )); + await tester.pump(); + + final image = _paintedImage(tester); + expect(image, isA()); + expect((image as NetworkImage).url, _remoteUrl); + // flutter_test answers every HTTP request with a 400; nothing else may + // throw (the old path dereferenced the null storage directory). + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + }); + + testWidgets('a placed image still uploading paints from memory on web', + (tester) async { + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + pendingBytes: {_imageId: _pendingPng}, + )); + await tester.pump(); + + final image = _paintedImage(tester); + expect(image, isA()); + expect((image as MemoryImage).bytes, _pendingPng); + expect(find.text('Syncing image'), findsNothing); + }); + + testWidgets('a cloud image whose URL has not arrived shows syncing', + (tester) async { + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + assets: [_asset(url: null, status: 'pending')], + )); + await tester.pump(); + + expect(find.byType(Image), findsNothing); + expect(find.text('Syncing image'), findsOneWidget); + }); + + testWidgets('a cloud image with no asset on the server shows unavailable', + (tester) async { + // The loaded page lists no asset for the image and nothing is + // uploading it, as for a copy's image whose bytes were deleted. + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + )); + await tester.pump(); + + expect(find.byType(Image), findsNothing); + expect(find.text('Syncing image'), findsNothing); + expect(find.text('Image unavailable'), findsOneWidget); + }); + + testWidgets('a cloud image this device has yet to upload shows syncing', + (tester) async { + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + uploads: [_queuedUpload()], + )); + await tester.pump(); + + expect(find.text('Syncing image'), findsOneWidget); + expect(find.text('Image unavailable'), findsNothing); + }); + + testWidgets( + 'before the signed-in account is known, a missing asset shows syncing', + (tester) async { + // At startup the upload queue cannot yet say whether this device is + // uploading the image, so it must not flash unavailable. + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + accountId: null, + )); + await tester.pump(); + + expect(find.text('Syncing image'), findsOneWidget); + expect(find.text('Image unavailable'), findsNothing); + }); + + testWidgets('an outbox it could not read keeps a missing asset syncing', + (tester) async { + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: null, + outboxReliable: false, + )); + await tester.pump(); + + expect(find.text('Syncing image'), findsOneWidget); + }); + + testWidgets('a local image paints from its file on desktop', + (tester) async { + final storage = await _storageWithImage(tester); + + await tester.pumpWidget(_imageApp( + source: StrategySource.local, + storageDirectory: storage.path, + )); + await tester.pump(); + + final image = _paintedImage(tester); + expect(image, isA()); + expect((image as FileImage).file.path, _imageFile(storage).path); + }); + + testWidgets('a cloud image already cached on desktop paints from its file', + (tester) async { + final storage = await _storageWithImage(tester); + + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: storage.path, + assets: [_asset()], + )); + await tester.pump(); + + expect(_paintedImage(tester), isA()); + }); + + testWidgets('a mounted image sees its file appear on the next rebuild', + (tester) async { + final storage = await _storageWithImage(tester); + await tester.runAsync(() => _imageFile(storage).delete()); + final rebuild = ValueNotifier(0); + addTearDown(rebuild.dispose); + + await tester.pumpWidget(_imageApp( + source: StrategySource.local, + storageDirectory: storage.path, + rebuild: rebuild, + )); + await tester.pump(); + expect(find.text('Image unavailable'), findsOneWidget); + + await tester.runAsync(() => _writeImage(storage)); + rebuild.value++; + await tester.pump(); + + expect(_paintedImage(tester), isA()); + }); + + testWidgets('a mounted image sees its file vanish on the next rebuild', + (tester) async { + final storage = await _storageWithImage(tester); + final rebuild = ValueNotifier(0); + addTearDown(rebuild.dispose); + + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: storage.path, + assets: [_asset()], + rebuild: rebuild, + )); + await tester.pump(); + expect(_paintedImage(tester), isA()); + + await tester.runAsync(() => _imageFile(storage).delete()); + rebuild.value++; + await tester.pump(); + + expect(_paintedImage(tester), isA()); + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + }); + + testWidgets('any media cache change re-checks the disk', (tester) async { + final storage = await _storageWithImage(tester); + await tester.runAsync(() => _imageFile(storage).delete()); + + await tester.pumpWidget(_imageApp( + source: StrategySource.cloud, + storageDirectory: storage.path, + assets: [_asset()], + )); + await tester.pump(); + expect(_paintedImage(tester), isA()); + + await tester.runAsync(() => _writeImage(storage)); + ProviderScope.containerOf(tester.element(find.byType(ImageWidget))) + .read(cloudMediaCacheProvider.notifier) + .resetStrategy('strategy-1'); + await tester.pump(); + + expect(_paintedImage(tester), isA()); + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + }); + }); + + testWidgets( + 'a lineup media tile paints an Image, not a DecorationImage, ' + 'so the web fallback can draw it', (tester) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final youtube = TextEditingController(); + final notes = TextEditingController(); + addTearDown(youtube.dispose); + addTearDown(notes.dispose); + + await tester.pumpWidget(ProviderScope( + overrides: _overrides( + source: StrategySource.cloud, + storageDirectory: null, + assets: [_asset()], + ), + child: ShadApp( + home: Scaffold( + body: SizedBox( + width: 900, + height: 800, + child: LineupMediaPage( + youtubeLinkController: youtube, + notesController: notes, + images: [SimpleImageData(id: _imageId, fileExtension: '.png')], + onAddImage: () {}, + onPasteImage: () {}, + onRemoveImage: (_) {}, + ), + ), + ), + ), + )); + await tester.pump(); + + final image = tester.widget(find.byType(Image)); + expect((image.image as NetworkImage).url, _remoteUrl); + final decorationImages = tester + .widgetList(find.byType(Container)) + .map((container) => container.decoration) + .whereType() + .where((decoration) => decoration.image != null); + expect(decorationImages, isEmpty); + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + }); + + testWidgets( + 'removing a lineup image never leaves its frame on the image that ' + 'moves into its tile', (tester) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final youtube = TextEditingController(); + final notes = TextEditingController(); + addTearDown(youtube.dispose); + addTearDown(notes.dispose); + const removedId = 'removed-image'; + // The removed image paints from memory; the next is still loading its + // cloud URL, the moment a reused gapless Image would show a stale frame. + final images = [ + SimpleImageData(id: removedId, fileExtension: '.png'), + SimpleImageData(id: _imageId, fileExtension: '.png'), + ]; + + await tester.pumpWidget(ProviderScope( + overrides: _overrides( + source: StrategySource.cloud, + storageDirectory: null, + assets: [_asset()], + pendingBytes: {removedId: _pendingPng}, + ), + child: ShadApp( + home: Scaffold( + body: SizedBox( + width: 900, + height: 800, + child: StatefulBuilder( + builder: (context, setState) => LineupMediaPage( + youtubeLinkController: youtube, + notesController: notes, + images: images, + onAddImage: () {}, + onPasteImage: () {}, + onRemoveImage: (index) => + setState(() => images.removeAt(index)), + ), + ), + ), + ), + ), + )); + await tester.pump(); + + final firstTile = find.byType(Image).first; + expect(tester.widget(firstTile).key, const ValueKey(removedId)); + expect(tester.widget(firstTile).image, isA()); + final removedState = tester.state(firstTile); + // flutter_test answers the next image's URL with a 400. + await tester.pump(); + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + + await tester.tap(find.byIcon(LucideIcons.x).first); + await tester.pump(); + + final moved = find.byType(Image).first; + expect(find.byType(Image), findsOneWidget); + expect(tester.widget(moved).key, const ValueKey(_imageId)); + expect(tester.widget(moved).image, isA()); + expect(identical(tester.state(moved), removedState), isFalse); + expect( + tester.takeException(), + anyOf(isNull, isA()), + ); + }); +} diff --git a/test/strategy_import_version_guard_test.dart b/test/strategy_import_version_guard_test.dart index c29d3c0d..99ad8f6a 100644 --- a/test/strategy_import_version_guard_test.dart +++ b/test/strategy_import_version_guard_test.dart @@ -7,20 +7,21 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/hive/hive_registration.dart'; -import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_models.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); test('version guard allows current and older versions', () { expect( - () => StrategyProvider.throwIfImportedVersionIsTooNewForTest( + () => StrategyImportExportService.throwIfImportedVersionIsTooNewForTest( Settings.versionNumber, ), returnsNormally, ); expect( - () => StrategyProvider.throwIfImportedVersionIsTooNewForTest( + () => StrategyImportExportService.throwIfImportedVersionIsTooNewForTest( Settings.versionNumber - 1, ), returnsNormally, @@ -45,8 +46,7 @@ void main() { addTearDown(container.dispose); await expectLater( - container - .read(strategyProvider.notifier) + StrategyImportExportService(container) .loadFromFilePath(badVersionFile.path), throwsA(isA()), ); diff --git a/test/strategy_integrity_test.dart b/test/strategy_integrity_test.dart index 76710222..7fbe6b7d 100644 --- a/test/strategy_integrity_test.dart +++ b/test/strategy_integrity_test.dart @@ -27,6 +27,8 @@ import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:path/path.dart' as path; class _IcaFixture { @@ -100,7 +102,8 @@ Future> _readIcaJson(File file) async { class _IcaHarness { _IcaHarness._(this.directory, this.container); - static const _pathProvider = MethodChannel('plugins.flutter.io/path_provider'); + static const _pathProvider = + MethodChannel('plugins.flutter.io/path_provider'); final Directory directory; final ProviderContainer container; @@ -143,7 +146,7 @@ class _IcaHarness { /// Imports [file] the way opening an .ica does and returns what was saved. Future importIca(File file) async { final before = strategies.keys.toSet(); - await container.read(strategyProvider.notifier).loadFromFilePath(file.path); + await StrategyImportExportService(container).loadFromFilePath(file.path); final added = strategies.keys.where((key) => !before.contains(key)); expect(added, hasLength(1), reason: path.basename(file.path)); return strategies.get(added.single)!; @@ -161,8 +164,7 @@ class _IcaHarness { if (!strategies.containsKey(strategy.id)) { await strategies.put(strategy.id, strategy); } - final exported = await container - .read(strategyProvider.notifier) + final exported = await StrategyImportExportService(container) .zipStrategy(id: strategy.id, saveDir: directory); return File(exported); } @@ -337,7 +339,8 @@ void main() { } }); - test('every historical fixture imports to the current schema and round-trips', + test( + 'every historical fixture imports to the current schema and round-trips', () async { final harness = await _IcaHarness.open(); addTearDown(harness.close); @@ -362,8 +365,8 @@ void main() { final reExported = await harness.exportIca(reImported); expect(await _readIcaJson(reExported), await _readIcaJson(exported), reason: fixture.name); - expect(await _icaAttachments(reExported), - await _icaAttachments(exported), + expect( + await _icaAttachments(reExported), await _icaAttachments(exported), reason: '${fixture.name} images'); } }); @@ -611,8 +614,7 @@ void main() { ); for (final invalidValue in ['true', 'yes', 1, null]) { - final json = page.toJson('strategy-id') - ..['isAutoNamed'] = invalidValue; + final json = page.toJson('strategy-id')..['isAutoNamed'] = invalidValue; await expectLater( StrategyPage.fromJson( @@ -698,7 +700,6 @@ void main() { 'fileExtension': '.png', 'scale': 220.0, 'tagColorValue': null, - 'link': '', }, ], 'utilityData': [ @@ -761,7 +762,8 @@ void main() { containsPair('showTraversalTime', true), ); final reImported = await harness.importIca(exportedFile); - final reExported = await _readIcaJson(await harness.exportIca(reImported)); + final reExported = + await _readIcaJson(await harness.exportIca(reImported)); _expectCustomShapes( reImported, @@ -826,7 +828,6 @@ void main() { 'aspectRatio': 1.0, 'fileExtension': '.png', 'scale': 9999.0, - 'link': '', }, ], 'utilityData': [], @@ -918,7 +919,7 @@ void main() { ], ); - final migrated = StrategyProvider.migrateToWorld16x9(source, force: true); + final migrated = StrategyMigrator.migrateToWorld16x9(source, force: true); final page = migrated.pages.single; final migratedLine = page.drawingData.first as Line; @@ -970,8 +971,7 @@ void main() { }); await expectLater( - harness.container - .read(strategyProvider.notifier) + StrategyImportExportService(harness.container) .loadFromFilePath(file.path), throwsA(isA()), ); @@ -988,8 +988,7 @@ void main() { }); await expectLater( - harness.container - .read(strategyProvider.notifier) + StrategyImportExportService(harness.container) .loadFromFilePath(file.path), throwsA(anyOf(isA(), isA())), ); diff --git a/test/strategy_op_queue_provider_test.dart b/test/strategy_op_queue_provider_test.dart new file mode 100644 index 00000000..5765c61f --- /dev/null +++ b/test/strategy_op_queue_provider_test.dart @@ -0,0 +1,4102 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/transport/convex_transport.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + group('Entity sync revision domains', () { + test('page descriptor and page content cannot coalesce together', () { + const descriptor = EntitySyncKey.pageDescriptor('page:1'); + const content = EntitySyncKey.pageContent('page:1'); + expect(descriptor, isNot(content)); + expect(descriptor.kind, EntitySyncKeyKind.pageDescriptor); + expect(content.kind, EntitySyncKeyKind.pageContent); + expect( + descriptor.overlayType, + ActivePageOverlayEntityType.pageDescriptor, + ); + expect(content.overlayType, ActivePageOverlayEntityType.pageContent); + expect(descriptor.toString(), 'page:page%3A1:descriptor'); + }); + }); + + group('durable strategy outbox', () { + late MemoryDurableStrategyOutboxStore store; + ProviderContainer? container; + + StrategyOpQueueNotifier start({ + String? accountId = 'account-a', + String? strategyId = 'strategy-1', + }) { + container?.dispose(); + container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + ]); + container! + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final notifier = container!.read(strategyOpQueueProvider.notifier); + notifier.setActiveStrategy(strategyId, accountId: accountId); + return notifier; + } + + StrategyOp elementOp({ + String opId = 'op-1', + String elementId = 'element-1', + String value = 'a', + StrategyOpKind kind = StrategyOpKind.patch, + }) { + return switch (kind) { + StrategyOpKind.add => ElementAddOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + payload: {'value': value}, + sortIndex: 0, + ), + StrategyOpKind.patch => ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + payload: {'value': value}, + sortIndex: 0, + expectedElementRevision: 1, + ), + StrategyOpKind.delete => ElementDeleteOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + expectedElementRevision: 1, + ), + StrategyOpKind.reorder => ElementReorderOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + sortIndex: 0, + expectedElementRevision: 1, + ), + }; + } + + DurableOutboxRecord record({ + required DurableOutboxStatus status, + String accountId = 'account-a', + String strategyId = 'strategy-1', + String opId = 'op-1', + int attempts = 0, + }) { + final op = elementOp(opId: opId); + return DurableOutboxRecord( + accountId: accountId, + strategyPublicId: strategyId, + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: PendingOp( + op: op, + clientId: 'stable-client', + attempts: attempts, + ), + status: status, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + } + + setUp(() => store = MemoryDurableStrategyOutboxStore()); + tearDown(() => container?.dispose()); + + test('restart after enqueue and before send restores the intent', () async { + final notifier = start(); + await notifier.enqueue(elementOp(), flushImmediately: false); + expect(store.values, hasLength(1)); + + start(); + final restored = container!.read(strategyOpQueueProvider); + expect(restored.pending, hasLength(1)); + expect(restored.pending.single.op.opId, 'op-1'); + expect(restored.pending.single.clientId, isNotEmpty); + }); + + test('page descriptor mutation survives restart in the durable outbox', + () async { + final notifier = start(); + await notifier.enqueue( + const PageAddOp( + opId: 'add-page', + pagePublicId: 'page-2', + payload: { + 'name': 'Execute', + 'isAttack': true, + 'settings': { + 'agentSize': 1.0, + 'abilitySize': 1.0, + 'useNeutralTeamColors': false, + }, + }, + sortIndex: 1, + expectedStrategyRevision: 4, + ), + flushImmediately: false, + ); + expect(store.values, hasLength(1)); + + start(); + + final restored = container!.read(strategyOpQueueProvider); + expect(restored.pending, hasLength(1)); + final intent = restored.queuedByEntityKey.entries.single; + expect(intent.key, const EntitySyncKey.pageDescriptor('page-2')); + expect(intent.value.pending.op.opId, 'add-page'); + expect(intent.value.pending.op.kind, StrategyOpKind.add); + expect(intent.value.pending.op.expectedRevision, 4); + }); + + test('restart while in flight replays the same event key', () async { + final saved = record(status: DurableOutboxStatus.inFlight); + await store.put(saved); + start(); + + final restored = container!.read(strategyOpQueueProvider); + expect(restored.queuedByEntityKey, hasLength(1)); + expect(restored.inFlightByEntityKey, isEmpty); + expect(restored.pending.single.op.opId, 'op-1'); + expect(restored.pending.single.clientId, 'stable-client'); + }); + + test('server-accepted crash window retains the exact op for replay', + () async { + final saved = + record(status: DurableOutboxStatus.inFlight, opId: 'accepted'); + await store.put(saved); + start(); + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, 'accepted'); + expect(pending.clientId, 'stable-client'); + }); + + test('strategy switch retains another page pending op', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, hasLength(1)); + }); + + test('sign-out and same-account recovery restores work', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy(null, accountId: null); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect(container!.read(strategyOpQueueProvider).pending, hasLength(1)); + }); + + test('different account cannot see or submit saved work', () async { + final notifier = start(); + await notifier.enqueue(elementOp()); + notifier.setActiveStrategy('strategy-1', accountId: 'account-b'); + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, hasLength(1)); + }); + + test('retry exhaustion is paused and manual retry keeps identity', + () async { + final saved = record( + status: DurableOutboxStatus.paused, + attempts: 8, + ); + await store.put(saved); + final notifier = start(); + expect(container!.read(strategyOpQueueProvider).needsAttention, isTrue); + expect(container!.read(strategyOpQueueProvider).pausedByEntityKey, + hasLength(1)); + + await notifier.retryPaused(flushImmediately: false); + final retried = container!.read(strategyOpQueueProvider); + expect(retried.pausedByEntityKey, isEmpty); + expect(retried.queuedByEntityKey, hasLength(1)); + expect(retried.pending.single.op.opId, 'op-1'); + expect(retried.pending.single.clientId, 'stable-client'); + }); + + test('corrupt persisted record produces attention and remains present', () { + store.values['broken'] = {'outboxVersion': 999}; + start(); + final loaded = container!.read(strategyOpQueueProvider); + expect(loaded.durableLoaded, isTrue); + expect(loaded.needsAttention, isTrue); + expect(loaded.loadIssues.single.storageKey, 'broken'); + expect(store.values, contains('broken')); + }); + + test('unknown future outbox records fail closed instead of converting', () { + final future = record(status: DurableOutboxStatus.queued).toJson() + ..['outboxVersion'] = durableOutboxRecordVersion + 1; + + expect( + () => DurableOutboxRecord.fromJson(future), + throwsA(isA()), + ); + }); + + test('reconciliation retains rejected work and updates its successor', + () async { + final saved = record(status: DurableOutboxStatus.attention).copyWith( + latestServerRevision: 7, + lastError: 'revision_mismatch', + ); + await store.put(saved); + var notifier = start(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): + elementOp(opId: 'replacement', value: 'new'), + }, + flushImmediately: false, + ); + var current = container!.read(strategyOpQueueProvider); + expect( + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'new'}, + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(current.successorByEntityKey, hasLength(1)); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): + elementOp(opId: 'newer-replacement', value: 'newest'), + }, + flushImmediately: false, + ); + + current = container!.read(strategyOpQueueProvider); + expect( + current.attentionByEntityKey.values.single.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey, isEmpty); + expect( + current.successorByEntityKey.values.single.pending.op.payload, + {'value': 'newest'}, + ); + var durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'op-1'); + expect(durable.successorPending!.op.payload, {'value': 'newest'}); + expect(durable.latestServerRevision, 7); + expect(durable.lastError, 'revision_mismatch'); + + await notifier.retryRejected(flushImmediately: false); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.successorByEntityKey, isEmpty); + final retry = current.queuedByEntityKey.values.single.pending.op; + expect(retry.opId, isNot(anyOf('op-1', 'newer-replacement'))); + expect(retry.payload, {'value': 'newest'}); + expect(retry.expectedRevision, 7); + durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.successorPending, isNull); + }); + + test('ordinary reconciliation does not discard attention work', () async { + final saved = record(status: DurableOutboxStatus.attention); + await store.put(saved); + final notifier = start(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: const {}, + flushImmediately: false, + ); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(store.values, hasLength(1)); + }); + + test( + 'cloud adoption discards only selected rejected work and survives restart', + () async { + const selectedKey = EntitySyncKey.element('page-1', 'element-1'); + const otherAttentionKey = EntitySyncKey.element('page-1', 'element-2'); + const queuedKey = EntitySyncKey.element('page-1', 'element-3'); + final selected = record(status: DurableOutboxStatus.attention).copyWith( + successorPending: PendingOp( + op: elementOp( + opId: 'selected-successor', + value: 'newer local intent', + ), + clientId: 'stable-client', + ), + latestServerRevision: 7, + ); + final otherAttention = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: otherAttentionKey, + pending: PendingOp( + op: elementOp(opId: 'other-rejected', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + latestServerRevision: 4, + ); + final queued = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: queuedKey, + pending: PendingOp( + op: elementOp(opId: 'unrelated-queued', elementId: 'element-3'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + final otherStrategy = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-2', + entityKey: selectedKey, + pending: PendingOp( + op: elementOp(opId: 'other-strategy'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(selected); + await store.put(otherAttention); + await store.put(queued); + await store.put(otherStrategy); + var notifier = start(); + + final discarded = await notifier.discardRejected({selectedKey}); + + expect(discarded, {selectedKey}); + var current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.successorByEntityKey, isEmpty); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect( + store.load().records.map((record) => record.pending.op.opId), + containsAll([ + 'other-rejected', + 'unrelated-queued', + 'other-strategy', + ]), + ); + expect( + store.load().records.map((record) => record.pending.op.opId), + isNot(contains('op-1')), + ); + expect( + store.load().records.expand((record) => [ + record.pending.op.opId, + if (record.successorPending != null) + record.successorPending!.op.opId, + ]), + isNot(contains('selected-successor')), + ); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + selectedKey: elementOp(opId: 'stale-reconciliation'), + }, + clearMissing: false, + flushImmediately: false, + ); + expect( + container!.read(strategyOpQueueProvider).queuedByEntityKey, + isNot(contains(selectedKey)), + ); + + notifier = start(); + current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(otherAttentionKey)); + expect(current.attentionByEntityKey, isNot(contains(selectedKey))); + expect(current.queuedByEntityKey, contains(queuedKey)); + expect(current.pending.map((pending) => pending.op.opId), + isNot(contains('selected-successor'))); + + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + expect( + container! + .read(strategyOpQueueProvider) + .attentionByEntityKey[selectedKey]! + .pending + .op + .opId, + 'other-strategy', + ); + }); + + test('partial cloud adoption leaves a failed durable delete in attention', + () async { + const firstKey = EntitySyncKey.element('page-1', 'element-1'); + const secondKey = EntitySyncKey.element('page-1', 'element-2'); + final failingStore = _FailingSelectedRemovalStore(); + store = failingStore; + await store.put(record(status: DurableOutboxStatus.attention)); + final second = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: secondKey, + pending: PendingOp( + op: elementOp(opId: 'second', elementId: 'element-2'), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(second); + failingStore.failStorageKey = second.storageKey; + final notifier = start(); + + final discarded = await notifier.discardRejected({firstKey, secondKey}); + + expect(discarded, {firstKey}); + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(secondKey)); + expect(current.attentionByEntityKey, isNot(contains(firstKey))); + expect(current.lastError, contains('could not be removed')); + expect( + store.load().records.map((record) => record.entityKey), + unorderedEquals([secondKey]), + ); + }); + + test('explicit rejected retry uses durable latest server revision', + () async { + final saved = record(status: DurableOutboxStatus.attention).copyWith( + latestServerRevision: 7, + ); + await store.put(saved); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey, hasLength(1)); + final retried = current.queuedByEntityKey.values.single.pending; + expect(retried.op.opId, isNot('op-1')); + expect(retried.op.expectedRevision, 7); + expect(retried.attempts, 0); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.latestServerRevision, isNull); + expect(durable.pending.op.opId, retried.op.opId); + }); + + test('explicit rejected retry falls back to the original revision', + () async { + await store.put(record(status: DurableOutboxStatus.attention)); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + final retried = current.queuedByEntityKey.values.single.pending; + expect(retried.op.opId, isNot('op-1')); + expect(retried.op.expectedRevision, 1); + }); + + test('explicit rejected retry preserves a tombstone restore add', () async { + const key = EntitySyncKey.element('page-1', 'element-1'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: const PendingOp( + op: ElementAddOp( + opId: 'restore-op', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'restore me'}, + sortIndex: 0, + expectedElementRevision: 2, + ), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: 'revision_mismatch', + latestServerRevision: 3, + )); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final retried = container! + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op; + expect(retried.opId, isNot('restore-op')); + expect(retried.kind, StrategyOpKind.add); + expect(retried.expectedRevision, 3); + }); + + test('explicit rejected retry converts an active add collision to patch', + () async { + const key = EntitySyncKey.element('page-1', 'element-1'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: PendingOp( + op: elementOp(kind: StrategyOpKind.add), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: 'already_exists', + latestServerRevision: 2, + )); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final retried = container! + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op; + expect(retried.kind, StrategyOpKind.patch); + expect(retried.expectedRevision, 2); + }); + + test('explicit rejected retry explains when no revision is available', + () async { + final saved = DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: PendingOp( + op: elementOp(kind: StrategyOpKind.add), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + ); + await store.put(saved); + final notifier = start(); + await notifier.retryRejected(flushImmediately: false); + + final current = container!.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, hasLength(1)); + expect(current.queuedByEntityKey, isEmpty); + expect(current.lastError, contains('cannot be retried automatically')); + }); + + test('coalesces add and patch only after durable replacement', () async { + final notifier = start(); + await notifier.enqueue(elementOp(kind: StrategyOpKind.add)); + await notifier.enqueue(elementOp(opId: 'patch', value: 'b')); + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.kind, StrategyOpKind.add); + expect(pending.op.opId, isNot(anyOf('op-1', 'patch'))); + expect(pending.op.payload, {'value': 'b'}); + expect(store.values, hasLength(1)); + expect((store.values.values.single as Map)['opId'], pending.op.opId); + }); + + test('byte-for-byte equivalent work keeps its durable op ID', () async { + final notifier = start(); + await notifier.enqueue(elementOp(), flushImmediately: false); + await notifier.enqueue( + elementOp(opId: 'unused-equivalent-id'), + flushImmediately: false, + ); + + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, 'op-1'); + expect((store.values.values.single as Map)['opId'], 'op-1'); + }); + + test('patch replacement gets a new durable op ID', () async { + final notifier = start(); + await notifier.enqueue(elementOp(), flushImmediately: false); + await notifier.enqueue( + elementOp(opId: 'desired-patch', value: 'b'), + flushImmediately: false, + ); + + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, isNot(anyOf('op-1', 'desired-patch'))); + expect(pending.op.payload, {'value': 'b'}); + expect((store.values.values.single as Map)['opId'], pending.op.opId); + }); + + test('reorder replacement gets a new durable op ID', () async { + final notifier = start(); + const first = PageReorderOp( + opId: 'reorder-a', + pagePublicId: 'page-1', + sortIndex: 1, + expectedStrategyRevision: 2, + ); + const second = PageReorderOp( + opId: 'reorder-b', + pagePublicId: 'page-1', + sortIndex: 3, + expectedStrategyRevision: 2, + ); + await notifier.enqueue(first, flushImmediately: false); + await notifier.enqueue(second, flushImmediately: false); + + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, isNot(anyOf('reorder-a', 'reorder-b'))); + expect(pending.op.sortIndex, 3); + }); + + test('delete cancels a queued add and removes its durable record', + () async { + final notifier = start(); + await notifier.enqueue( + elementOp(kind: StrategyOpKind.add), + flushImmediately: false, + ); + await notifier.enqueue( + elementOp(opId: 'delete', kind: StrategyOpKind.delete), + flushImmediately: false, + ); + + expect(container!.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('restoring after a queued delete creates new immutable work', + () async { + final notifier = start(); + await notifier.enqueue( + elementOp(opId: 'delete', kind: StrategyOpKind.delete), + flushImmediately: false, + ); + await notifier.enqueue( + elementOp(opId: 'restore', kind: StrategyOpKind.add, value: 'restored'), + flushImmediately: false, + ); + + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, isNot(anyOf('delete', 'restore'))); + expect(pending.op.kind, StrategyOpKind.add); + expect(pending.op.payload, {'value': 'restored'}); + }); + + test('lost response then new work cannot replay the applied op ID', + () async { + await store.put( + record(status: DurableOutboxStatus.inFlight, opId: 'applied-a'), + ); + final notifier = start(); + + await notifier.enqueue( + elementOp(opId: 'work-b', value: 'b'), + flushImmediately: false, + ); + + final pending = container!.read(strategyOpQueueProvider).pending.single; + expect(pending.op.opId, isNot(anyOf('applied-a', 'work-b'))); + expect(pending.op.payload, {'value': 'b'}); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.pending.op.opId, pending.op.opId); + expect(durable.pending.op.payload, {'value': 'b'}); + }); + }); + + test('provider state waits for durable persistence before showing Syncing', + () async { + final store = _BlockingStore(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + ]); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final enqueue = notifier.enqueue(const ElementPatchOp( + opId: 'op-1', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + )); + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + store.allowWrite.complete(); + await enqueue; + expect(container.read(strategyOpQueueProvider).pending, hasLength(1)); + }); + + test('replacement stays hidden until the durable record is written', + () async { + final store = _BlockingReplacementStore(); + final container = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + ]); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + const first = ElementPatchOp( + opId: 'first', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'a'}, + expectedElementRevision: 1, + ); + const desired = ElementPatchOp( + opId: 'desired', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'b'}, + expectedElementRevision: 1, + ); + await notifier.enqueue(first, flushImmediately: false); + + final replacement = notifier.enqueue(desired, flushImmediately: false); + await Future.delayed(Duration.zero); + final beforeWrite = container.read(strategyOpQueueProvider).pending.single; + expect(beforeWrite.op.opId, 'first'); + expect(beforeWrite.op.payload, {'value': 'a'}); + + store.allowReplacement.complete(); + await replacement; + final afterWrite = container.read(strategyOpQueueProvider).pending.single; + expect(afterWrite.op.opId, isNot(anyOf('first', 'desired'))); + expect(afterWrite.op.payload, {'value': 'b'}); + }); + + test('cloud adoption leaves unrelated in-flight work untouched', () async { + const rejectedKey = EntitySyncKey.element('page-1', 'element-1'); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: rejectedKey, + pending: const PendingOp( + op: ElementPatchOp( + opId: 'rejected', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.attention, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const inFlightKey = EntitySyncKey.element('page-1', 'element-2'); + await notifier.enqueue(const ElementPatchOp( + opId: 'unrelated-in-flight', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'other'}, + expectedElementRevision: 1, + )); + final flush = notifier.flushNow(); + await repository.firstStarted.future; + + final discarded = await notifier.discardRejected({rejectedKey}); + + expect(discarded, {rejectedKey}); + expect( + container.read(strategyOpQueueProvider).inFlightByEntityKey, + contains(inFlightKey), + ); + repository.completeFirst(const AppliedOpAck( + opId: 'unrelated-in-flight', + revision: 2, + )); + await flush; + }); + + group('cloud payload policy', () { + test('serialized operation size counts Unicode UTF-8 bytes', () { + final op = _largeElementPatch( + opId: 'unicode-size', + elementId: 'element-unicode', + value: _repeat('界', 3), + ); + final serialized = jsonEncode(op.toConvexJson()); + + expect( + serializedCloudOperationUtf8Bytes(op), + utf8.encode(serialized).length, + ); + expect(utf8.encode(serialized).length, greaterThan(serialized.length)); + }); + + test( + 'an initial durable enqueue failure stays unreliable until the exact ' + 'record is rewritten', () async { + final store = _FirstPutFailureStore(); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const op = ElementPatchOp( + opId: 'initial-write-failure', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'unsaved'}, + expectedElementRevision: 1, + ); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(op, flushImmediately: false); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey[key]!.pending.op, op); + expect(current.hasDurabilityFailure, isTrue); + expect(current.needsAttention, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(current.lastError, contains('could not be saved')); + expect(store.values, isEmpty); + + await notifier.enqueue(op, flushImmediately: false); + + current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isFalse); + expect(current.outboxIsReliable, isTrue); + expect(current.lastError, isNull); + expect(store.load().records.single.pending.op.opId, op.opId); + }); + + test('a lineup refused for another page stays saved and keeps that reason', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final container = _cloudQueueContainer( + store: store, + repository: _FailingLineupRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + final lineup = _lineupAdd(opId: 'add-lineup', pageId: 'page-2'); + final key = EntitySyncKey.forStrategyOp(lineup)!; + + await notifier.enqueue(lineup, flushImmediately: false); + await notifier.flushNow(); + + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey[key]!.pending.op.opId, 'add-lineup'); + expect(current.needsAttention, isTrue); + // The sync button explains the refusal, not a generic conflict. + expect(current.lastError, lineupPageMismatchMessage); + expect(isSpecificAttentionReason(current.lastError!), isTrue); + final durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect( + friendlyCloudSyncError(durable.lastError!), + contains('clashes with one on another page'), + ); + }); + + test( + 'a lineup group refused for overlapping another stays saved and keeps ' + 'that reason', () async { + final store = MemoryDurableStrategyOutboxStore(); + final container = _cloudQueueContainer( + store: store, + repository: _FailingLineupRepository( + code: 'INVALID_LINEUP_PAYLOAD_DATA', + message: lineupOverlapMessage, + ), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + final lineup = _lineupAdd(opId: 'add-group'); + final key = EntitySyncKey.forStrategyOp(lineup)!; + + await notifier.enqueue(lineup, flushImmediately: false); + await notifier.flushNow(); + + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey[key]!.pending.op.opId, 'add-group'); + expect(current.lastError, lineupOverlapMessage); + expect(isSpecificAttentionReason(current.lastError!), isTrue); + final durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.lastError, lineupOverlapMessage); + expect(friendlyCloudSyncError(durable.lastError!), + contains('shares a spot')); + }); + + test('opening a strategy shows its saved lineup refusal', () async { + final store = MemoryDurableStrategyOutboxStore(); + await store.put(_savedRecord( + _lineupAdd(opId: 'refused-lineup'), + status: DurableOutboxStatus.attention, + lastError: lineupPageMismatchMessage, + )); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier); + + // Straight after a restart, and after switching from another strategy. + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect( + container.read(strategyOpQueueProvider).lastError, + lineupPageMismatchMessage, + ); + notifier.setActiveStrategy('strategy-2', accountId: 'account-a'); + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + expect( + container.read(strategyOpQueueProvider).lastError, + lineupPageMismatchMessage, + ); + }); + + test('undoing an edit to a refused lineup keeps why it was refused', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final container = _cloudQueueContainer( + store: store, + repository: _FailingLineupRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + LineupAddOp lineup(String name) => + _lineupAdd(opId: 'add-$name', name: name); + final key = EntitySyncKey.forStrategyOp(lineup('first'))!; + + await notifier.enqueue(lineup('first'), flushImmediately: false); + await notifier.flushNow(); + await notifier.syncDesiredGenericOp( + entityKey: key, + desiredOp: lineup('edited'), + ); + expect(container.read(strategyOpQueueProvider).successorByEntityKey, + contains(key)); + await notifier.syncDesiredGenericOp( + entityKey: key, + desiredOp: lineup('first'), + ); + + final current = container.read(strategyOpQueueProvider); + expect(current.successorByEntityKey, isEmpty); + expect(current.lastError, lineupPageMismatchMessage); + expect(store.load().records.single.lastError, lineupPageMismatchMessage); + }); + + // A conflict, and a failed validation that is not one. + for (final otherReason in [ + 'revision_mismatch', + 'Lineup key does not match its payload', + ]) { + test('a lineup refusal beside other refused work notes it ($otherReason)', + () async { + final store = MemoryDurableStrategyOutboxStore(); + const element = ElementPatchOp( + opId: 'stale-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ); + for (final (StrategyOp op, reason) in [ + (_lineupAdd(opId: 'refused-lineup'), lineupPageMismatchMessage), + (element, otherReason), + ]) { + await store.put(_savedRecord( + op, + status: DurableOutboxStatus.attention, + lastError: reason, + latestServerRevision: 2, + )); + } + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + + container + .read(strategyOpQueueProvider.notifier) + .setActiveStrategy('strategy-1', accountId: 'account-a'); + + final error = container.read(strategyOpQueueProvider).lastError!; + expect(error, contains(lineupPageMismatchMessage)); + expect(error, contains(otherWorkNeedsAttentionNote)); + }); + } + + test('an old-format lineup add is shown, never sent, and kept on restart', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final retired = _retiredLinkAdd(); + final retiredKey = EntitySyncKey.forStrategyOp(retired)!; + await store.put(_savedRecord(_cloudElementOp())); + await store.put(_savedRecord(retired)); + var repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + // Shown from the moment the strategy opens, before anything is sent. + var current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey.keys, [ + EntitySyncKey.forStrategyOp(_cloudElementOp()), + ]); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.lastError, retiredLineupOpMessage); + + await notifier.flushNow(); + await _settle(); + + // The rest of the strategy's work lands; the old change never leaves + // the device. + expect( + repository.calls.map((ops) => ops.map((op) => op.opId).toList()), + [ + ['op-1'], + ], + ); + current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, isEmpty); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.needsAttention, isTrue); + expect(current.lastError, retiredLineupOpMessage); + expect(isSpecificAttentionReason(current.lastError!), isTrue); + expect( + friendlyCloudSyncError(current.lastError!), + contains('older version of Icarus'), + ); + // Only the copy in memory changed: the saved record is as the older + // build left it. + final durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.pending.op.opId, retired.opId); + expect(durable.pending.op.payload, retired.payload); + + // After a restart it is still there, still explained, still unsent. + container.dispose(); + repository = _RecordingAckRepository(); + container = _cloudQueueContainer(store: store, repository: repository); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + await _settle(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.lastError, retiredLineupOpMessage); + expect(repository.calls, isEmpty); + expect(store.load().records.single.pending.op.opId, retired.opId); + }); + + // B4: whatever state an older build left an old-format record in, it + // waits in attention from the moment the outbox loads, where the + // canvas's reconciliation never reaches it. Left queued, paused or in + // flight it would otherwise be reconciled away (or sent) by the first + // sync of its page. + for (final (status, attempts, lastError) in [ + (DurableOutboxStatus.queued, 0, null), + // Paused after transport failures, with no successor. + (DurableOutboxStatus.paused, 8, 'Connection lost while sending'), + (DurableOutboxStatus.inFlight, 1, null), + // Refused by an older build for a reason of its own. + ( + DurableOutboxStatus.attention, + 1, + "This lineup's origin or landing spot is no longer on the page", + ), + ]) { + test( + 'an old-format lineup add left ${status.name} waits in attention ' + 'from load, through a sync of its page', () async { + final store = MemoryDurableStrategyOutboxStore(); + final retired = _retiredLinkAdd(); + final retiredKey = EntitySyncKey.forStrategyOp(retired)!; + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: retiredKey, + pending: PendingOp( + op: retired, + clientId: 'client-a', + attempts: attempts, + lastAttemptAt: attempts == 0 ? null : DateTime(2026), + ), + status: status, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: lastError, + )); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.queuedByEntityKey, isEmpty); + expect(current.pausedByEntityKey, isEmpty); + expect(current.inFlightByEntityKey, isEmpty); + expect(current.lastError, retiredLineupOpMessage); + + // The canvas syncs the page with other work, clearing what it no + // longer has. + const element = ElementAddOp( + opId: 'element-add', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'new'}, + sortIndex: 0, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + EntitySyncKey.forStrategyOp(element)!: element, + }, + clearMissing: true, + ); + await notifier.flushNow(); + await _settle(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect( + current.attentionByEntityKey[retiredKey]!.pending.op.opId, + retired.opId, + ); + expect(current.lastError, retiredLineupOpMessage); + expect( + repository.calls.expand((ops) => ops).map((op) => op.opId), + ['element-add'], + ); + // The element landed and left the outbox; the old change is still + // saved, exactly as the older build saved it. + final durable = store.load().records.single; + expect(durable.entityKey, retiredKey); + expect(durable.pending.op.opId, retired.opId); + expect(durable.pending.op.payload, retired.payload); + expect(durable.status, status); + }); + } + + test('an old-format lineup delete is shown, and alone sends nothing', + () async { + final store = MemoryDurableStrategyOutboxStore(); + const retired = LineupDeleteOp( + opId: 'old-origin-delete', + lineupPublicId: 'lineupOrigin:o', + pagePublicId: 'page-1', + expectedLineupRevision: 1, + ); + final retiredKey = EntitySyncKey.forStrategyOp(retired)!; + await store.put(_savedRecord(retired)); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.flushNow(); + await _settle(); + + expect(repository.calls, isEmpty); + final current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, isEmpty); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.lastError, retiredLineupOpMessage); + final durable = store.load().records.single; + expect(durable.pending.op.opId, retired.opId); + }); + + test('keep mine leaves an old-format lineup change waiting; discard drops', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final retired = _retiredLinkAdd(); + final retiredKey = EntitySyncKey.forStrategyOp(retired)!; + const element = ElementPatchOp( + opId: 'stale-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ); + final elementKey = EntitySyncKey.forStrategyOp(element)!; + await store.put(_savedRecord( + element, + status: DurableOutboxStatus.attention, + lastError: 'revision_mismatch', + latestServerRevision: 2, + )); + await store.put(_savedRecord(retired)); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + await _settle(); + expect(repository.calls, isEmpty); + expect( + container.read(strategyOpQueueProvider).lastError, + '$retiredLineupOpMessage. $otherWorkNeedsAttentionNote', + ); + + // Keep mine re-sends the conflict and leaves the old change, however + // often it is chosen. + await notifier.retryRejected(flushImmediately: false); + var current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey.keys, [elementKey]); + expect(current.attentionByEntityKey.keys, [retiredKey]); + expect(current.lastError, retiredLineupOpMessage); + await notifier.retryRejected(flushImmediately: false); + current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, isNot(contains(retiredKey))); + // Alone in attention it still says why, not that no revision matched. + expect(current.lastError, retiredLineupOpMessage); + expect( + current.attentionByEntityKey[retiredKey]!.pending.op.opId, + retired.opId, + ); + final durable = store.load().records.singleWhere( + (record) => record.entityKey == retiredKey, + ); + expect(durable.pending.op.opId, retired.opId); + expect(durable.pending.op.payload, retired.payload); + expect( + repository.calls.expand((ops) => ops).map((op) => op.opId), + isNot(contains(retired.opId)), + ); + + final discarded = await notifier.discardRejected({retiredKey}); + + expect(discarded, {retiredKey}); + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.lastError, isNull); + expect( + store.load().records.map((record) => record.entityKey), + isNot(contains(retiredKey)), + ); + }); + + test('an oversized op is durably parked while independent work lands', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final oversized = _largeElementPatch( + opId: 'oversized', + elementId: 'element-large', + ); + const valid = ElementPatchOp( + opId: 'independent', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ); + expect( + serializedCloudOperationUtf8Bytes(oversized), + greaterThan(maxCloudOperationBytes), + ); + await notifier.enqueue(oversized, flushImmediately: false); + await notifier.enqueue(valid, flushImmediately: false); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(false); + + await notifier.flushNow(); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.map((op) => op.opId), ['independent']); + var current = container.read(strategyOpQueueProvider); + const oversizedKey = EntitySyncKey.element('page-1', 'element-large'); + expect(current.attentionByEntityKey, contains(oversizedKey)); + expect(current.queuedByEntityKey, isEmpty); + expect(current.lastError, cloudOperationTooLargeMessage); + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized'); + expect(durable.lastError, cloudOperationTooLargeMessage); + + container.dispose(); + repository.calls.clear(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(oversizedKey)); + expect(current.lastError, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized'); + }); + + test('an over-wide array is parked before independent transport', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + final wide = ElementPatchOp( + opId: 'wide-array', + elementPublicId: 'element-wide', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'points': List.filled(maxCloudArrayEntries + 1, 0)}, + }, + expectedElementRevision: 1, + ); + expect( + serializedCloudOperationUtf8Bytes(wide), + lessThan(maxCloudOperationBytes), + ); + expect(cloudOperationExceedsPolicy(wide), isTrue); + await notifier.enqueue(wide, flushImmediately: false); + await notifier.enqueue( + const ElementPatchOp( + opId: 'wide-array-sibling', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, + ); + + await notifier.flushNow(); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single.map((op) => op.opId), [ + 'wide-array-sibling', + ]); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-wide')), + ); + expect( + store.load().records.single.lastError, cloudOperationTooLargeMessage); + }); + + test('a failed oversized parking write blocks all transport and retries', + () async { + final store = _OversizedParkingFailureStore(); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-write-failure', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.enqueue( + const ElementPatchOp( + opId: 'independent-after-write-failure', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, + ); + + await notifier.flushNow(); + await Future.delayed(const Duration(milliseconds: 400)); + + final current = container.read(strategyOpQueueProvider); + expect(repository.calls, isEmpty); + expect(current.outboxIsReliable, isFalse); + expect(current.hasDurabilityFailure, isTrue); + expect( + current.attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-large')), + ); + expect( + current.queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-small')), + ); + expect(current.lastError, contains('Nothing was sent')); + expect(store.attentionWrites, 1); + }); + + test('a missing durable oversized record blocks all transport', () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-missing-record', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.enqueue( + const ElementPatchOp( + opId: 'independent-after-missing-record', + elementPublicId: 'element-small', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ), + flushImmediately: false, + ); + + await notifier.flushNow(); + await Future.delayed(const Duration(milliseconds: 400)); + + final current = container.read(strategyOpQueueProvider); + expect(repository.calls, isEmpty); + expect(current.outboxIsReliable, isFalse); + expect(current.hasDurabilityFailure, isTrue); + expect( + current.attentionByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-large')), + ); + expect( + current.queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'element-small')), + ); + expect(current.lastError, contains('Nothing was sent')); + expect( + store.load().records.map((record) => record.pending.op.opId), + isNot(contains('oversized-missing-record')), + ); + expect(store.attentionWrites, 1); + }); + + test( + 'a legacy queued oversized record parks without overwrite and survives ' + 'restart', () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final oversized = _largeElementPatch( + opId: 'legacy-oversized', + elementId: 'element-large', + ); + const key = EntitySyncKey.element('page-1', 'element-large'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: PendingOp(op: oversized, clientId: 'stable-client'), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.flushNow(); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.queuedByEntityKey, isEmpty); + expect(repository.calls, isEmpty); + expect(store.attentionWrites, 0); + expect(store.load().records.single.status, DurableOutboxStatus.queued); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.lastError, cloudOperationTooLargeMessage); + expect(repository.calls, isEmpty); + expect(store.attentionWrites, 0); + expect(store.load().records.single.pending.op.opId, oversized.opId); + + final discarded = await notifier.discardRejected({key}); + expect(discarded, {key}); + expect(store.load().records, isEmpty); + }); + + test('Use cloud clears an uncertain oversized first-write failure', + () async { + final store = _OversizedParkingFailureStore(dropBeforeThrow: true); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-large'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-explicit-discard', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.flushNow(); + + var current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(store.load().records, isEmpty); + + final discarded = await notifier.discardRejected({key}); + + expect(discarded, {key}); + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.hasDurabilityFailure, isFalse); + expect(current.outboxIsReliable, isTrue); + expect(current.lastError, isNull); + expect(store.load().records, isEmpty); + expect(store.removalAttempts, 1); + }); + + test('Use cloud remains fail-closed when uncertain removal fails', + () async { + final store = _OversizedParkingFailureStore(failRemove: true); + final container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-large'); + await notifier.enqueue( + _largeElementPatch( + opId: 'oversized-failed-discard', + elementId: 'element-large', + ), + flushImmediately: false, + ); + await notifier.flushNow(); + + final discarded = await notifier.discardRejected({key}); + + expect(discarded, isEmpty); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + expect(current.hasDurabilityFailure, isTrue); + expect(current.outboxIsReliable, isFalse); + expect(current.lastError, contains('could not be removed')); + expect(store.load().records, isEmpty); + expect(store.removalAttempts, 1); + }); + + test('batches split below the conservative argument byte cap', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + for (var index = 0; index < 20; index += 1) { + await notifier.enqueue( + _largeElementPatch( + opId: 'batch-$index', + elementId: 'element-$index', + value: _repeat('x', 820 * 1024), + ), + flushImmediately: false, + ); + } + final allOps = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .values + .map((intent) => intent.pending.op) + .toList(growable: false); + expect( + serializedCloudBatchUtf8Bytes( + strategyPublicId: 'strategy-1', + clientId: container.read(strategyOpQueueProvider).clientId!, + ops: allOps, + ), + greaterThan(maxCloudBatchBytes), + ); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(false); + + await notifier.flushNow(); + await repository.secondCall.future; + for (var index = 0; + index < 10 && + container.read(strategyOpQueueProvider).pending.isNotEmpty; + index += 1) { + await Future.delayed(Duration.zero); + } + + expect(repository.calls, hasLength(2)); + expect(repository.calls.expand((batch) => batch), hasLength(20)); + for (final batch in repository.calls) { + expect( + serializedCloudBatchUtf8Bytes( + strategyPublicId: 'strategy-1', + clientId: container.read(strategyOpQueueProvider).clientId!, + ops: batch, + ), + lessThanOrEqualTo(maxCloudBatchBytes), + ); + } + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('an oversized same-entity successor is retained in attention', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + await notifier.enqueue(_elementPatch( + opId: 'safe-predecessor', + value: 'safe', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + final oversizedSuccessor = _largeElementPatch( + opId: 'oversized-successor', + elementId: 'element-1', + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: {key: oversizedSuccessor}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'safe-predecessor', + revision: 2, + )); + await firstFlush; + for (var index = 0; + index < 10 && + container + .read(strategyOpQueueProvider) + .attentionByEntityKey + .isEmpty; + index += 1) { + await Future.delayed(Duration.zero); + } + + var current = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(current.attentionByEntityKey, contains(key)); + expect(current.successorByEntityKey, isEmpty); + expect(current.lastError, cloudOperationTooLargeMessage); + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.payload, oversizedSuccessor.payload); + expect(durable.lastError, cloudOperationTooLargeMessage); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: _RecordingAckRepository(), + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.flushNow(); + + current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, contains(key)); + durable = store.load().records.single; + expect(durable.pending.op.payload, oversizedSuccessor.payload); + }); + + test('a valid successor behind an oversized predecessor can land', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _RecordingAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: repository, + ); + container + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + await notifier.enqueue( + ElementAddOp( + opId: 'oversized-predecessor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': _repeat('界', 310000)}, + }, + sortIndex: 0, + ), + flushImmediately: false, + ); + await notifier.flushNow(); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'valid-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': 'reduced drawing'}, + }, + sortIndex: 0, + ), + }, + ); + + var durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'oversized-predecessor'); + expect(durable.successorPending?.op.opId, 'valid-successor'); + + container.dispose(); + container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.retryRejected(flushImmediately: false); + await notifier.flushNow(); + + expect(repository.calls, hasLength(1)); + expect(repository.calls.single, hasLength(1)); + expect(repository.calls.single.single, isA()); + expect(repository.calls.single.single.payload, { + 'kind': 'drawing', + 'payloadVersion': 1, + 'data': {'encodedPoints': 'reduced drawing'}, + }); + expect( + serializedCloudOperationUtf8Bytes(repository.calls.single.single), + lessThanOrEqualTo(maxCloudOperationBytes), + ); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + }); + + group('acknowledgement persistence recovery', () { + test('accepted ack remove failure restores the batch for retry', () async { + final store = _OneShotAckFailureStore(failRemove: true); + final container = _cloudQueueContainer( + store: store, + repository: _AckRepository(reject: false), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.enqueue(_cloudElementOp(), flushImmediately: false); + await notifier.flushNow(); + + _expectBatchRestored(container, store); + }); + + test('rejected ack put failure restores the batch for retry', () async { + final store = _OneShotAckFailureStore(failAttentionPut: true); + final container = _cloudQueueContainer( + store: store, + repository: _AckRepository(reject: true), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + await notifier.enqueue(_cloudElementOp(), flushImmediately: false); + await notifier.flushNow(); + + _expectBatchRestored(container, store); + }); + }); + + group('page descriptor final intent', () { + test('rebases and sends the final side after an in-flight side patch', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.pageDescriptor('page-1'); + + await notifier.enqueue(_pageSideOp( + opId: 'defense', + isAttack: false, + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _pageSideOp( + opId: 'attack', + isAttack: true, + expectedRevision: 1, + ), + }, + ); + + final duringFirst = container.read(strategyOpQueueProvider); + expect(duringFirst.inFlightByEntityKey[key]!.pending.op.opId, 'defense'); + expect( + duringFirst.successorByEntityKey[key]!.pending.op.payload, + {'isAttack': true}, + ); + final durableDuringFirst = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableDuringFirst.pending.op.opId, 'defense'); + expect(durableDuringFirst.successorPending!.op.payload, { + 'isAttack': true, + }); + + repository.completeFirst(const AppliedOpAck( + opId: 'defense', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as PagePatchOp; + expect(promoted.payload, {'isAttack': true}); + expect(promoted.expectedPageRevision, 2); + expect(promoted.opId, isNot('attack')); + + repository.completeSecond(AppliedOpAck( + opId: promoted.opId, + revision: 3, + )); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('restart replays the predecessor before its durable final side', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.pageDescriptor('page-1'); + + await notifier.enqueue(_pageSideOp( + opId: 'defense-before-restart', + isAttack: false, + expectedRevision: 4, + )); + unawaited(notifier.flushNow()); + await firstRepository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _pageSideOp( + opId: 'attack-after-restart', + isAttack: true, + expectedRevision: 4, + ), + }, + ); + container.dispose(); + + final replayRepository = _SequencedAckRepository(); + container = _cloudQueueContainer( + store: store, + repository: replayRepository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await replayRepository.firstStarted.future; + + final replayed = replayRepository.calls.first.single as PagePatchOp; + expect(replayed.opId, 'defense-before-restart'); + expect(replayed.payload, {'isAttack': false}); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[key]! + .pending + .op + .payload, + {'isAttack': true}, + ); + + replayRepository.completeFirst(const AppliedOpAck( + opId: 'defense-before-restart', + revision: 5, + )); + await replayRepository.secondStarted.future; + final finalSide = replayRepository.calls[1].single as PagePatchOp; + expect(finalSide.payload, {'isAttack': true}); + expect(finalSide.expectedPageRevision, 5); + replayRepository.completeSecond(AppliedOpAck( + opId: finalSide.opId, + revision: 6, + )); + await replayRepository.secondCompleted.future; + }); + }); + + group('same-entity final intent', () { + test('keeps an element successor behind its in-flight predecessor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-edit', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-edit', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + final duringFirst = container.read(strategyOpQueueProvider); + expect( + duringFirst.inFlightByEntityKey[key]!.pending.op.opId, + 'first-edit', + ); + expect( + duringFirst.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableDuringFirst = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableDuringFirst.pending.op.opId, 'first-edit'); + expect( + durableDuringFirst.successorPending!.op.payload, + {'value': 'second'}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'first-edit', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as ElementPatchOp; + expect(promoted.payload, {'value': 'second'}); + expect(promoted.expectedElementRevision, 2); + expect(promoted.opId, isNot('second-edit')); + + repository.completeSecond(AppliedOpAck( + opId: promoted.opId, + revision: 3, + )); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(store.values, isEmpty); + }); + + test('a replayed predecessor rebases its successor where it was applied', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-edit', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-edit', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + // The server applied first-edit at 2, its ack was lost, and a + // teammate's edit took the row to 3 before first-edit was sent again. + // The replay is answered as a noop at the revision first-edit was + // applied at, not the row's revision now. + repository.completeFirst(const NoopOpAck( + opId: 'first-edit', + currentRevision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final promoted = repository.calls[1].single as ElementPatchOp; + expect(promoted.payload, {'value': 'second'}); + expect(promoted.expectedElementRevision, 2); + + // So the server, its row at 3, refuses it rather than letting it + // overwrite the teammate's edit unseen. + repository.completeSecond( + promoted.expectedElementRevision == 3 + ? AppliedOpAck(opId: promoted.opId, revision: 4) + : RejectedOpAck( + opId: promoted.opId, + rejectionReason: OpRejectionReason.revisionMismatch, + current: const ElementCurrentSnapshot( + revision: 3, + value: {'value': 'teammate'}, + ), + ), + ); + await repository.secondCompleted.future; + await _settle(); + + final current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, isEmpty); + expect(current.successorByEntityKey, isEmpty); + expect( + current.attentionByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final record = store.load().records.single; + expect(record.status, DurableOutboxStatus.attention); + expect(record.pending.op.opId, promoted.opId); + expect(record.lastError, OpRejectionReason.revisionMismatch.wireName); + expect(record.latestServerRevision, 3); + }); + + test('restart replays an element predecessor before its successor', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'first-before-restart', + value: 'first', + expectedRevision: 4, + )); + unawaited(notifier.flushNow()); + await firstRepository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second-after-restart', + value: 'second', + expectedRevision: 4, + ), + }, + ); + container.dispose(); + + final replayRepository = _SequencedAckRepository(); + container = _cloudQueueContainer( + store: store, + repository: replayRepository, + ); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await replayRepository.firstStarted.future; + + final replayed = replayRepository.calls.first.single as ElementPatchOp; + expect(replayed.opId, 'first-before-restart'); + expect(replayed.payload, {'value': 'first'}); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[key]! + .pending + .op + .payload, + {'value': 'second'}, + ); + + replayRepository.completeFirst(const AppliedOpAck( + opId: 'first-before-restart', + revision: 5, + )); + await replayRepository.secondStarted.future; + final finalEdit = replayRepository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 5); + replayRepository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 6, + )); + await replayRepository.secondCompleted.future; + }); + + for (final editedAfterRestart in [false, true]) { + test( + 'acks say which work was in the outbox before the restart ' + '(final edit made ${editedAfterRestart ? 'after' : 'before'} it)', + () async { + final store = MemoryDurableStrategyOutboxStore(); + const key = EntitySyncKey.element('page-1', 'element-1'); + final firstRepository = _SequencedAckRepository(); + var container = _cloudQueueContainer( + store: store, + repository: firstRepository, + ); + var notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue(_elementPatch( + opId: 'first', + value: 'first', + expectedRevision: 4, + )); + Future editAgain() => notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'second', + value: 'second', + expectedRevision: 4, + ), + }, + ); + if (!editedAfterRestart) { + unawaited(notifier.flushNow()); + await firstRepository.firstStarted.future; + await editAgain(); + } + container.dispose(); + + final repository = _SequencedAckRepository(); + container = _cloudQueueContainer(store: store, repository: repository); + addTearDown(container.dispose); + notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await repository.firstStarted.future; + if (editedAfterRestart) await editAgain(); + List acked() => + container.read(strategyOpQueueProvider).lastAckBatch; + + repository + .completeFirst(const AppliedOpAck(opId: 'first', revision: 5)); + await repository.secondStarted.future; + expect(acked().single.op.opId, 'first'); + expect(acked().single.restored, isTrue); + + final finalEdit = repository.calls[1].single; + expect(finalEdit.payload, {'value': 'second'}); + repository.completeSecond( + AppliedOpAck(opId: finalEdit.opId, revision: 6), + ); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(acked().single.op.opId, finalEdit.opId); + expect(acked().single.restored, !editedAfterRestart); + }); + } + + test('a final edit promoted while its strategy opens lands restored', + () async { + final store = _GatedPromotionStore(); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: const EntitySyncKey.element('page-1', 'element-1'), + pending: PendingOp( + op: _elementPatch(opId: 'first', value: 'first', expectedRevision: 4), + clientId: 'stable-client', + ), + successorPending: PendingOp( + op: _elementPatch( + opId: 'second', value: 'second', expectedRevision: 4), + clientId: 'stable-client', + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _SequencedAckRepository(); + final container = + _cloudQueueContainer(store: store, repository: repository); + addTearDown(container.dispose); + // Another strategy is open; this one's work replays in the background. + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-2', accountId: 'account-a'); + await repository.firstStarted.future; + repository.completeFirst(const AppliedOpAck(opId: 'first', revision: 5)); + // The user opens it while the promoted final edit is being stored. + await store.promotionStarted.future; + notifier.setActiveStrategy('strategy-1', accountId: 'account-a'); + store.releasePromotion.complete(); + + await repository.secondStarted.future; + final finalEdit = repository.calls[1].single; + expect(finalEdit.payload, {'value': 'second'}); + repository.completeSecond( + AppliedOpAck(opId: finalEdit.opId, revision: 6), + ); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + final acked = container.read(strategyOpQueueProvider).lastAckBatch; + expect(acked.single.op.opId, finalEdit.opId); + expect(acked.single.restored, isTrue); + }); + + test('work sent before the canvas was drawn fresh lands restored', + () async { + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: MemoryDurableStrategyOutboxStore(), + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _elementPatch(opId: 'first', value: 'first', expectedRevision: 4), + ); + unawaited(notifier.flushNow()); + await repository.firstStarted.future; + // The user leaves for a local strategy and comes back: the queue + // stays on this one while the canvas is drawn again from the server. + notifier.forgetCanvasWork(); + expect(notifier.canvasWorkCount, 0); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + const EntitySyncKey.element('page-1', 'element-1'): _elementPatch( + opId: 'second', value: 'second', expectedRevision: 4), + }, + ); + expect(notifier.canvasWorkCount, 1); + List acked() => + container.read(strategyOpQueueProvider).lastAckBatch; + + repository.completeFirst(const AppliedOpAck(opId: 'first', revision: 5)); + await repository.secondStarted.future; + expect(acked().single.op.opId, 'first'); + expect(acked().single.restored, isTrue); + // The edit made since follows it onto the new revision, still the + // canvas's work. + expect(notifier.canvasWorkCount, 1); + + final finalEdit = repository.calls[1].single; + repository.completeSecond( + AppliedOpAck(opId: finalEdit.opId, revision: 6), + ); + await repository.secondCompleted.future; + await Future.delayed(Duration.zero); + expect(acked().single.op.opId, finalEdit.opId); + expect(acked().single.restored, isFalse); + // Nothing is remembered for work that has landed. + expect(notifier.canvasWorkCount, 0); + }); + + test('recovered work kept while its ack is being stored is not resent', + () async { + final store = _GatedRemoveStore(); + final recovered = + _elementPatch(opId: 'recovered', value: 'new', expectedRevision: 1); + const key = EntitySyncKey.element('page-1', 'element-1'); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: key, + pending: PendingOp(op: recovered, clientId: 'stable-client'), + status: DurableOutboxStatus.queued, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + )); + final repository = _SequencedAckRepository(); + final container = + _cloudQueueContainer(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await repository.firstStarted.future; + repository + .completeFirst(const AppliedOpAck(opId: 'recovered', revision: 2)); + await store.removeStarted.future; + // Still shown in flight: the page keeps it, unchanged, as its intent. + expect( + container + .read(strategyOpQueueProvider) + .inFlightByEntityKey[key]! + .pending + .op + .opId, + 'recovered', + ); + final kept = notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: {key: recovered}, + ); + store.releaseRemove.complete(); + await kept; + final queue = container.read(strategyOpQueueProvider); + expect(queue.lastAckBatch.single.restored, isTrue); + expect(queue.pending, isEmpty); + expect(store.values, isEmpty); + expect(notifier.canvasWorkCount, 0); + // Past the send debounce: nothing replays it. + await Future.delayed(const Duration(milliseconds: 300)); + expect(repository.calls, hasLength(1)); + }); + + test('an ack stored after the canvas is drawn fresh lands restored', + () async { + final store = _GatedRemoveStore(); + final repository = _SequencedAckRepository(); + final container = + _cloudQueueContainer(store: store, repository: repository); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _elementPatch(opId: 'edit', value: 'edit', expectedRevision: 1), + ); + unawaited(notifier.flushNow()); + await repository.firstStarted.future; + repository.completeFirst(const AppliedOpAck(opId: 'edit', revision: 2)); + await store.removeStarted.future; + // The canvas is reset and drawn again from the server while the ack + // is being stored. + notifier.forgetCanvasWork(); + store.releaseRemove.complete(); + await Future.delayed(Duration.zero); + await notifier.flushNow(); + final acked = container.read(strategyOpQueueProvider).lastAckBatch; + expect(acked.single.op.opId, 'edit'); + expect(acked.single.restored, isTrue); + }); + + test('discarding refused work forgets it as the canvas\'s', () async { + final container = _cloudQueueContainer( + store: MemoryDurableStrategyOutboxStore(), + repository: _AckRepository(reject: true), + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue( + _elementPatch(opId: 'edit', value: 'edit', expectedRevision: 1), + ); + await notifier.flushNow(); + const key = EntitySyncKey.element('page-1', 'element-1'); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + contains(key), + ); + expect(notifier.canvasWorkCount, 1); + await notifier.discardRejected({key}); + expect(notifier.canvasWorkCount, 0); + }); + + test('rejected predecessor leaves its element successor in attention', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(_elementPatch( + opId: 'conflicting-first', + value: 'first', + expectedRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second', + value: 'second', + expectedRevision: 1, + ), + }, + ); + + repository.completeFirst(const RejectedOpAck( + opId: 'conflicting-first', + rejectionReason: OpRejectionReason.revisionMismatch, + current: ElementCurrentSnapshot(revision: 2, value: {'value': 'peer'}), + )); + await firstFlush; + await Future.delayed(Duration.zero); + + final conflicted = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(conflicted.attentionByEntityKey, contains(key)); + expect( + conflicted.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.attention); + expect(durable.pending.op.opId, 'conflicting-first'); + expect(durable.successorPending!.op.payload, {'value': 'second'}); + expect(durable.latestServerRevision, 2); + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _elementPatch( + opId: 'retained-second-again', + value: 'second', + expectedRevision: 1, + ), + }, + flushImmediately: true, + ); + await Future.delayed(Duration.zero); + + final reconciled = container.read(strategyOpQueueProvider); + expect(repository.calls, hasLength(1)); + expect(reconciled.attentionByEntityKey, contains(key)); + expect(reconciled.queuedByEntityKey, isEmpty); + expect( + reconciled.successorByEntityKey[key]!.pending.op.payload, + {'value': 'second'}, + ); + final durableAfterReconcile = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableAfterReconcile.status, DurableOutboxStatus.attention); + expect(durableAfterReconcile.pending.op.opId, 'conflicting-first'); + expect( + durableAfterReconcile.successorPending!.op.payload, + {'value': 'second'}, + ); + expect(durableAfterReconcile.latestServerRevision, 2); + + await notifier.retryRejected(flushImmediately: true); + await repository.secondStarted.future; + final retried = repository.calls[1].single as ElementPatchOp; + expect(retried.opId, isNot('retained-second')); + expect(retried.payload, {'value': 'second'}); + expect(retried.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: retried.opId, + revision: 3, + )); + await repository.secondCompleted.future; + }); + + test('promotes an edit after an element add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as ElementPatchOp; + expect(finalEdit.payload, {'value': 'second'}); + expect(finalEdit.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('promotes an edit after a lineup add as a patch', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + final successor = _lineupAdd( + opId: 'lineup-add-successor', + id: 'lineup-1', + name: 'second', + ); + + await notifier.enqueue(_lineupAdd( + opId: 'lineup-add-in-flight', + id: 'lineup-1', + name: 'first', + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: {key: successor}, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalEdit = repository.calls[1].single as LineupPatchOp; + expect(finalEdit.lineupPublicId, 'lineup-1'); + expect(finalEdit.payload, successor.payload); + expect(finalEdit.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalEdit.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a restore add after an accepted element delete', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementDeleteOp( + opId: 'element-delete-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 1, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-restore-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'restored'}, + sortIndex: 0, + expectedElementRevision: 1, + ), + }, + ); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-delete-in-flight', + revision: 2, + )); + await firstFlush; + await repository.secondStarted.future; + + final restore = repository.calls[1].single as ElementAddOp; + expect(restore.payload, {'value': 'restored'}); + expect(restore.expectedElementRevision, 2); + repository.completeSecond(AppliedOpAck( + opId: restore.opId, + revision: 3, + )); + await repository.secondCompleted.future; + }); + + test('keeps a final element delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.element('page-1', 'element-1'); + + await notifier.enqueue(const ElementAddOp( + opId: 'element-add-in-flight', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'first'}, + sortIndex: 0, + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'element-add-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'second'}, + sortIndex: 0, + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementDeleteOp( + opId: 'element-delete-successor', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + expectedElementRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'element-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'element-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as ElementDeleteOp; + expect(finalDelete.expectedElementRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + + test('keeps a final lineup delete behind an in-flight add', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _SequencedAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + const key = EntitySyncKey.lineup('page-1', 'lineup-1'); + + await notifier.enqueue(_lineupAdd( + opId: 'lineup-add-in-flight', + id: 'lineup-1', + name: 'first', + )); + final firstFlush = notifier.flushNow(); + await repository.firstStarted.future; + + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: _lineupAdd( + opId: 'lineup-add-successor', + id: 'lineup-1', + name: 'second', + ), + }, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const LineupDeleteOp( + opId: 'lineup-delete-successor', + lineupPublicId: 'lineup-1', + pagePublicId: 'page-1', + expectedLineupRevision: 0, + ), + }, + ); + + final durableBeforeAck = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durableBeforeAck.pending.op.opId, 'lineup-add-in-flight'); + expect(durableBeforeAck.successorPending!.op, isA()); + + repository.completeFirst(const AppliedOpAck( + opId: 'lineup-add-in-flight', + revision: 1, + )); + await firstFlush; + await repository.secondStarted.future; + + final finalDelete = repository.calls[1].single as LineupDeleteOp; + expect(finalDelete.expectedLineupRevision, 1); + repository.completeSecond(AppliedOpAck( + opId: finalDelete.opId, + revision: 2, + )); + await repository.secondCompleted.future; + }); + }); + + group('keep mine after a teammate deleted the row', () { + Future<(ProviderContainer, StrategyOpQueueNotifier)> refused( + _TombstoneRepository repository, + MemoryDurableStrategyOutboxStore store, + List ops, + ) async { + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + for (final op in ops) { + await notifier.enqueue(op, flushImmediately: false); + } + await notifier.flushNow(); + await _settle(); + + final current = container.read(strategyOpQueueProvider); + expect( + current.attentionByEntityKey.keys, + unorderedEquals( + [for (final op in ops) EntitySyncKey.forStrategyOp(op)]), + ); + for (final record in store.load().records) { + expect(record.status, DurableOutboxStatus.attention); + expect(record.lastError, OpRejectionReason.deleted.wireName); + expect(record.latestServerRevision, 2); + } + return (container, notifier); + } + + test('an element edit comes back as an add over the tombstone', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + const patch = ElementPatchOp( + opId: 'move-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 4, + expectedElementRevision: 1, + ); + final key = EntitySyncKey.forStrategyOp(patch)!; + final (container, notifier) = await refused(repository, store, [patch]); + + await notifier.retryRejected(flushImmediately: false); + + final restore = + container.read(strategyOpQueueProvider).queuedByEntityKey[key]!; + expect( + restore.pending.op, + isA() + .having((op) => op.elementPublicId, 'elementPublicId', 'element-1') + .having((op) => op.pagePublicId, 'pagePublicId', 'page-1') + .having((op) => op.payload, 'payload', {'value': 'moved'}) + .having((op) => op.sortIndex, 'sortIndex', 4) + .having( + (op) => op.expectedElementRevision, + 'expectedElementRevision', + 2, + ), + ); + expect(restore.pending.op.opId, isNot(patch.opId)); + expect( + store.load().records.single.pending.op.opId, + restore.pending.op.opId, + ); + + await notifier.flushNow(); + await _settle(); + + expect(repository.restored.keys, ['element-1']); + expect(repository.restored['element-1']!.opId, restore.pending.op.opId); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey, isEmpty); + expect(current.lastError, isNull); + expect(store.load().records, isEmpty); + }); + + test('a lineup edit comes back as an add over the tombstone', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + final payload = _lineupAdd(opId: 'unused', name: 'moved').payload; + final patch = LineupPatchOp( + opId: 'move-lineup', + lineupPublicId: 'k', + pagePublicId: 'page-1', + payload: payload, + sortIndex: 3, + expectedLineupRevision: 1, + ); + final key = EntitySyncKey.forStrategyOp(patch)!; + final (container, notifier) = await refused(repository, store, [patch]); + + await notifier.retryRejected(flushImmediately: false); + + final restore = + container.read(strategyOpQueueProvider).queuedByEntityKey[key]!; + expect( + restore.pending.op, + isA() + .having((op) => op.lineupPublicId, 'lineupPublicId', 'k') + .having((op) => op.pagePublicId, 'pagePublicId', 'page-1') + .having((op) => op.payload, 'payload', payload) + .having((op) => op.sortIndex, 'sortIndex', 3) + .having( + (op) => op.expectedLineupRevision, + 'expectedLineupRevision', + 2, + ), + ); + expect(restore.pending.op.opId, isNot(patch.opId)); + + await notifier.flushNow(); + await _settle(); + + expect(repository.restored.keys, ['k']); + expect(repository.restored['k']!.opId, restore.pending.op.opId); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey, isEmpty); + expect(store.load().records, isEmpty); + }); + + test('a reorder, or an edit without its place, cannot come back', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + final ops = [ + const ElementReorderOp( + opId: 'reorder-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + sortIndex: 2, + expectedElementRevision: 1, + ), + const LineupReorderOp( + opId: 'reorder-lineup', + lineupPublicId: 'k', + pagePublicId: 'page-1', + sortIndex: 2, + expectedLineupRevision: 1, + ), + // A patch that does not say where the element sits. + const ElementPatchOp( + opId: 'patch-no-sort', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + expectedElementRevision: 1, + ), + ]; + final (container, notifier) = await refused(repository, store, ops); + + await notifier.retryRejected(flushImmediately: false); + // Keep mine says why it could not help, not that a revision is missing. + expect( + container.read(strategyOpQueueProvider).lastError, + teammateDeletedCannotRestoreMessage, + ); + await notifier.flushNow(); + await _settle(); + + final current = container.read(strategyOpQueueProvider); + expect(current.queuedByEntityKey, isEmpty); + expect( + { + for (final intent in current.attentionByEntityKey.values) + intent.pending.op.opId, + }, + {for (final op in ops) op.opId}, + ); + expect(current.lastError, isNotNull); + expect(repository.calls, hasLength(1)); + expect(repository.restored, isEmpty); + final durable = store.load().records; + expect( + {for (final record in durable) record.pending.op.opId}, + {for (final op in ops) op.opId}, + ); + for (final record in durable) { + expect(record.status, DurableOutboxStatus.attention); + expect(record.lastError, OpRejectionReason.deleted.wireName); + } + }); + + test( + 'work deleted on both sides is settled, and keep mine cannot revive it', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + const elementPatch = ElementPatchOp( + opId: 'move-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 4, + expectedElementRevision: 1, + ); + final lineupPatch = LineupPatchOp( + opId: 'move-lineup', + lineupPublicId: 'k', + pagePublicId: 'page-1', + payload: _lineupAdd(opId: 'unused', name: 'moved').payload, + sortIndex: 3, + expectedLineupRevision: 1, + ); + // Refused alongside them, and not deleted on the canvas. + const unrelatedPatch = ElementPatchOp( + opId: 'move-other', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'other'}, + sortIndex: 5, + expectedElementRevision: 1, + ); + final elementKey = EntitySyncKey.forStrategyOp(elementPatch)!; + final lineupKey = EntitySyncKey.forStrategyOp(lineupPatch)!; + final unrelatedKey = EntitySyncKey.forStrategyOp(unrelatedPatch)!; + final (container, notifier) = await refused( + repository, + store, + [elementPatch, lineupPatch, unrelatedPatch], + ); + + // The user deleted element-1 and lineup k on the canvas. + final settled = await notifier.settleAttention({elementKey, lineupKey}); + + expect(settled, {elementKey, lineupKey}); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey.keys, + [unrelatedKey], + ); + expect( + [for (final record in store.load().records) record.entityKey], + [unrelatedKey], + ); + + // Keep mine brings back only the work still in attention. + await notifier.retryRejected(flushImmediately: false); + expect( + container.read(strategyOpQueueProvider).queuedByEntityKey.keys, + [unrelatedKey], + ); + await notifier.flushNow(); + await _settle(); + expect(repository.restored.keys, ['element-2']); + expect(repository.calls, hasLength(2)); + expect( + [for (final op in repository.calls.last) op.entityPublicId], + ['element-2'], + ); + + // Settling adopts nothing from the server: a later edit to a settled + // entity is queued and sent as any other. + const restore = ElementAddOp( + opId: 'add-again', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'again'}, + sortIndex: 4, + expectedElementRevision: 2, + ); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: {elementKey: restore}, + clearMissing: false, + ); + expect( + container + .read(strategyOpQueueProvider) + .queuedByEntityKey[elementKey]! + .pending + .op + .payload, + {'value': 'again'}, + ); + await notifier.flushNow(); + await _settle(); + expect(repository.restored.keys, ['element-2', 'element-1']); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey, isEmpty); + expect(current.queuedByEntityKey, isEmpty); + expect(store.load().records, isEmpty); + }); + + test( + 'work deleted on both sides stays in attention when its refusal was ' + 'not a deletion', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + // Refused as deleted: a teammate deleted element-1. + const elementPatch = ElementPatchOp( + opId: 'move-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 4, + expectedElementRevision: 1, + ); + // Refused as a revision mismatch: an add of lineup group k against a + // revision it no longer has. That says nothing about whether the + // server deleted k, never had it, or holds it elsewhere. + final staleAdd = LineupAddOp( + opId: 'stale-add', + lineupPublicId: 'k', + pagePublicId: 'page-1', + payload: _lineupAdd(opId: 'unused').payload, + sortIndex: 0, + expectedLineupRevision: 1, + ); + final elementKey = EntitySyncKey.forStrategyOp(elementPatch)!; + final lineupKey = EntitySyncKey.forStrategyOp(staleAdd)!; + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue(elementPatch, flushImmediately: false); + await notifier.enqueue(staleAdd, flushImmediately: false); + await notifier.flushNow(); + await _settle(); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey.keys, + unorderedEquals([elementKey, lineupKey]), + ); + expect(notifier.refusedAsDeleted(elementKey), isTrue); + expect(notifier.refusedAsDeleted(lineupKey), isFalse); + + // The user deleted both on the canvas. + final settled = await notifier.settleAttention({elementKey, lineupKey}); + + // Only the work refused as deleted is dropped; the other still waits, + // in memory and on disk, for the user to choose. + expect(settled, {elementKey}); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey.keys, + [lineupKey], + ); + expect(container.read(strategyOpQueueProvider).needsAttention, isTrue); + final records = store.load().records; + expect([for (final record in records) record.entityKey], [lineupKey]); + expect(records.single.status, DurableOutboxStatus.attention); + expect(records.single.lastError, + OpRejectionReason.revisionMismatch.wireName); + }); + + test('a conditional discard drops only the work still as the user saw it', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + const first = ElementPatchOp( + opId: 'move-1', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 4, + expectedElementRevision: 1, + ); + const second = ElementPatchOp( + opId: 'move-2', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 5, + expectedElementRevision: 1, + ); + final firstKey = EntitySyncKey.forStrategyOp(first)!; + final secondKey = EntitySyncKey.forStrategyOp(second)!; + final (container, notifier) = + await refused(repository, store, [first, second]); + // What the user chose to discard: both refused edits, nothing behind. + final seen = { + firstKey: ('move-1', null), + secondKey: ('move-2', null), + }; + + // Then the user edits element-2 again; it waits behind the refusal. + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + secondKey: const ElementPatchOp( + opId: 'newer', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'newer'}, + sortIndex: 5, + expectedElementRevision: 1, + ), + }, + clearMissing: false, + ); + expect( + container + .read(strategyOpQueueProvider) + .successorByEntityKey[secondKey]! + .pending + .op + .opId, + 'newer', + ); + + final discarded = + await notifier.discardRejected({firstKey, secondKey}, onlyIf: seen); + + // element-1's work is as seen, so it goes; element-2's changed, so it + // stays, the newer edit with it, in memory and on disk. + expect(discarded, {firstKey}); + final current = container.read(strategyOpQueueProvider); + expect(current.attentionByEntityKey.keys, [secondKey]); + expect(current.successorByEntityKey[secondKey]!.pending.op.opId, 'newer'); + final records = store.load().records; + expect([for (final record in records) record.entityKey], [secondKey]); + expect(records.single.pending.op.opId, 'move-2'); + expect(records.single.successorPending!.op.opId, 'newer'); + }); + + test('discarding instead waits for the server copy to be adopted', + () async { + // The contrast that makes settling distinct: after Use cloud the + // canvas is about to be redrawn from the server, so desired work for + // the entity is ignored until adoption completes. + final store = MemoryDurableStrategyOutboxStore(); + final repository = _TombstoneRepository(); + const elementPatch = ElementPatchOp( + opId: 'move-element', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'moved'}, + sortIndex: 4, + expectedElementRevision: 1, + ); + final key = EntitySyncKey.forStrategyOp(elementPatch)!; + final (container, notifier) = + await refused(repository, store, [elementPatch]); + + await notifier.discardRejected({key}); + await notifier.syncDesiredOpsForPage( + pageId: 'page-1', + desiredOpsByEntityKey: { + key: const ElementAddOp( + opId: 'add-again', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'again'}, + sortIndex: 4, + expectedElementRevision: 2, + ), + }, + clearMissing: false, + ); + + expect( + container.read(strategyOpQueueProvider).queuedByEntityKey, + isEmpty, + ); + }); + }); + + group('a server that needs a newer Icarus', () { + const key = EntitySyncKey.element('page-1', 'element-1'); + + DurableOutboxRecord saved( + ElementPatchOp op, { + required DurableOutboxStatus status, + required int attempts, + required String lastError, + }) { + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: EntitySyncKey.forStrategyOp(op)!, + pending: PendingOp( + op: op, + clientId: 'old-client', + attempts: attempts, + lastAttemptAt: DateTime(2026), + ), + status: status, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: lastError, + ); + } + + test('holds the work, uncounted, however often it is refused', () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _UpgradeRequiredRepository(); + // Each start is a reload into a build the server still refuses; more + // starts than the pause limit allows failures. + for (var start = 0; start < 10; start++) { + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + if (start == 0) await notifier.enqueue(_cloudElementOp()); + await notifier.flushNow(); + await _settle(); + // Held after the refusal: no second send in this process. + await notifier.flushNow(); + await _settle(); + + expect(container.read(clientUpgradeRequiredProvider), isTrue); + final current = container.read(strategyOpQueueProvider); + expect(current.pausedByEntityKey, isEmpty); + expect(current.attentionByEntityKey, isEmpty); + expect(current.needsAttention, isFalse); + expect(current.queuedByEntityKey[key]!.pending.op.opId, 'op-1'); + expect(current.queuedByEntityKey[key]!.pending.attempts, 0); + expect(current.lastError, clientUpgradeRequiredQueueError); + container.dispose(); + } + + expect(repository.calls, 10); + final durable = store.load().records.single; + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.pending.attempts, 0); + expect( + durable.pending.op.toConvexJson(), + _cloudElementOp().toConvexJson(), + ); + expect(durable.pending.clientId, isNotEmpty); + }); + + test('resumes work an older build paused for it, and only that', () async { + final store = MemoryDurableStrategyOutboxStore(); + const refused = ElementPatchOp( + opId: 'refused', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'kept'}, + expectedElementRevision: 1, + ); + const failing = ElementPatchOp( + opId: 'failing', + elementPublicId: 'element-2', + pagePublicId: 'page-1', + payload: {'value': 'other'}, + expectedElementRevision: 1, + ); + // As the protocol 3 web build kept them after eight refusals, and one + // paused for an ordinary failure. + await store.put(saved( + refused, + status: DurableOutboxStatus.paused, + attempts: 8, + lastError: 'ConvexFunctionException(CLIENT_UPGRADE_REQUIRED, ' + 'Client upgrade required)', + )); + await store.put(saved( + failing, + status: DurableOutboxStatus.paused, + attempts: 8, + // A different refusal whose text merely mentions the code. + lastError: 'ConvexFunctionException(INVALID_PAYLOAD, name was ' + 'CLIENT_UPGRADE_REQUIRED)', + )); + final repository = _RecordingAckRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + + final loaded = container.read(strategyOpQueueProvider); + expect( + loaded.queuedByEntityKey[key]!.pending.op.toConvexJson(), + refused.toConvexJson(), + ); + expect(loaded.queuedByEntityKey[key]!.pending.clientId, 'old-client'); + expect(loaded.queuedByEntityKey[key]!.pending.attempts, 0); + expect( + loaded.pausedByEntityKey.values.single.pending.op.opId, + 'failing', + ); + + await notifier.flushNow(); + await _settle(); + + expect( + repository.calls.map((ops) => ops.map((op) => op.opId).toList()), + [ + ['refused'], + ], + ); + final left = store.load().records.single; + expect(left.pending.op.toConvexJson(), failing.toConvexJson()); + expect(left.status, DurableOutboxStatus.paused); + expect(left.pending.attempts, 8); + }); + + test('sends the held work once the server accepts this build again', + () async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _UpgradeRequiredRepository(); + final container = _cloudQueueContainer( + store: store, + repository: repository, + ); + addTearDown(container.dispose); + final notifier = container.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('strategy-1', accountId: 'account-a'); + await notifier.enqueue(_cloudElementOp()); + await notifier.flushNow(); + await _settle(); + expect(container.read(clientUpgradeRequiredProvider), isTrue); + expect(repository.calls, 1); + + // A rolled-back deploy: the server accepts this protocol again. + repository.refusing = false; + await container.read(clientUpgradeRequiredProvider.notifier).recheck(); + await _settle(); + + expect(container.read(clientUpgradeRequiredProvider), isFalse); + expect(repository.calls, 2); + expect(store.load().records, isEmpty); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + }); +} + +/// Lets sends already started, and the writes after them, finish. +Future _settle() async { + for (var i = 0; i < 20; i++) { + await Future.delayed(Duration.zero); + } +} + +/// The server refusing this build's protocol, as it refuses every batch from +/// a client older than the current one. +class _UpgradeRequiredRepository extends ConvexStrategyRepository { + _UpgradeRequiredRepository() : super(IcarusConvexApi(_UnusedTransport())); + + int calls = 0; + bool refusing = true; + + @override + Future serverAcceptsCloudProtocol() async => !refusing; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls += 1; + if (!refusing) { + return [for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2)]; + } + throw const ConvexFunctionException( + code: ConvexErrorCode.clientUpgradeRequired, + rawCode: 'CLIENT_UPGRADE_REQUIRED', + message: 'Client upgrade required', + ); + } +} + +ProviderContainer _cloudQueueContainer({ + required DurableStrategyOutboxStore store, + required ConvexStrategyRepository repository, +}) { + return ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_CloudReadyAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ]); +} + +ElementPatchOp _cloudElementOp() { + return const ElementPatchOp( + opId: 'op-1', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'safe'}, + expectedElementRevision: 1, + ); +} + +/// An add of lineup [id] as one row, the shape this build sends. +LineupAddOp _lineupAdd({ + required String opId, + String id = 'k', + String pageId = 'page-1', + String name = 'lineup', +}) { + return LineupAddOp( + opId: opId, + lineupPublicId: id, + pagePublicId: pageId, + payload: cloudLineupsPayload({ + 'id': id, + 'origins': [ + {'id': 'o', 'agent': {}}, + ], + 'landings': [ + {'id': 'l', 'ability': {}}, + ], + 'links': [ + {'id': id, 'originId': 'o', 'landingId': 'l', 'name': name}, + ], + }), + sortIndex: 0, + ); +} + +/// A link add as builds before one row per lineup wrote it to their outbox: +/// its own row, keyed `lineupLink:`, naming its origin and landing. +LineupAddOp _retiredLinkAdd() { + return const LineupAddOp( + opId: 'old-link-add', + lineupPublicId: 'lineupLink:k', + pagePublicId: 'page-1', + payload: { + 'kind': 'lineupLink', + 'payloadVersion': 1, + 'data': {'id': 'k', 'originId': 'o', 'landingId': 'l'}, + }, + sortIndex: 0, + ); +} + +/// [op] as saved in the durable outbox of account-a's strategy-1. +DurableOutboxRecord _savedRecord( + StrategyOp op, { + DurableOutboxStatus status = DurableOutboxStatus.queued, + String? lastError, + int? latestServerRevision, +}) { + return DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-1', + entityKey: EntitySyncKey.forStrategyOp(op)!, + pending: PendingOp(op: op, clientId: 'client-a'), + status: status, + createdAt: DateTime(2026), + updatedAt: DateTime(2026), + lastError: lastError, + latestServerRevision: latestServerRevision, + ); +} + +PagePatchOp _pageSideOp({ + required String opId, + required bool isAttack, + required int expectedRevision, +}) { + return PagePatchOp( + opId: opId, + pagePublicId: 'page-1', + payload: {'isAttack': isAttack}, + expectedPageRevision: expectedRevision, + ); +} + +ElementPatchOp _elementPatch({ + required String opId, + required String value, + required int expectedRevision, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': value}, + expectedElementRevision: expectedRevision, + ); +} + +ElementPatchOp _largeElementPatch({ + required String opId, + required String elementId, + String? value, +}) { + return ElementPatchOp( + opId: opId, + elementPublicId: elementId, + pagePublicId: 'page-1', + payload: {'value': value ?? _repeat('界', 310000)}, + expectedElementRevision: 1, + ); +} + +String _repeat(String value, int count) => List.filled(count, value).join(); + +void _expectBatchRestored( + ProviderContainer container, + MemoryDurableStrategyOutboxStore store, +) { + final current = container.read(strategyOpQueueProvider); + expect(current.isFlushing, isFalse); + expect(current.inFlightByEntityKey, isEmpty); + expect(current.queuedByEntityKey, hasLength(1)); + expect(current.queuedByEntityKey.values.single.pending.attempts, 1); + final durable = DurableOutboxRecord.fromJson( + Map.from(store.values.values.single as Map), + ); + expect(durable.status, DurableOutboxStatus.queued); + expect(durable.pending.attempts, 1); +} + +class _CloudReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _AckRepository extends ConvexStrategyRepository { + _AckRepository({required this.reject}) + : super(IcarusConvexApi(_UnusedTransport())); + + final bool reject; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + return [ + for (final op in ops) + if (reject) + RejectedOpAck( + opId: op.opId, + rejectionReason: OpRejectionReason.revisionMismatch, + ) + else + AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +class _RecordingAckRepository extends ConvexStrategyRepository { + _RecordingAckRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List> calls = []; + final secondCall = Completer(); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add(List.from(ops)); + if (calls.length == 2 && !secondCall.isCompleted) secondCall.complete(); + return [ + for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2), + ]; + } +} + +/// Refuses every op as the server refuses a lineup whose row lives on +/// another page. +class _FailingLineupRepository extends ConvexStrategyRepository { + _FailingLineupRepository({ + this.code = 'LINEUP_PAGE_MISMATCH', + this.message = lineupPageMismatchMessage, + }) : super(IcarusConvexApi(_UnusedTransport())); + + final String code; + final String message; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + return [ + for (final op in ops) + FailedOpAck( + opId: op.opId, + code: code, + rawCode: code, + message: message, + ), + ]; + } +} + +/// A server on which a teammate deleted elements element-1 and element-2 and +/// lineup k, each tombstone at revision 2. A patch or reorder of a deleted +/// row is refused as deleted; an add expecting the tombstone's revision +/// brings the row back. +class _TombstoneRepository extends ConvexStrategyRepository { + _TombstoneRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final List> calls = []; + final _deleted = {'element-1', 'element-2', 'k'}; + + /// The add that restored each row, by its id. + final Map restored = {}; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add(List.from(ops)); + return [for (final op in ops) _apply(op)]; + } + + OpAck _apply(StrategyOp op) { + final id = op.entityPublicId!; + if (!_deleted.contains(id)) return AppliedOpAck(opId: op.opId, revision: 2); + if (op.kind == StrategyOpKind.add && op.expectedRevision == 2) { + _deleted.remove(id); + restored[id] = op; + return AppliedOpAck(opId: op.opId, revision: 3); + } + return RejectedOpAck( + opId: op.opId, + rejectionReason: op.kind == StrategyOpKind.add + ? OpRejectionReason.revisionMismatch + : OpRejectionReason.deleted, + current: op.entityType == StrategyOpEntityType.lineup + ? const LineupCurrentSnapshot(revision: 2, value: {}) + : const ElementCurrentSnapshot(revision: 2, value: {}), + ); + } +} + +class _SequencedAckRepository extends ConvexStrategyRepository { + _SequencedAckRepository() : super(IcarusConvexApi(_UnusedTransport())); + + final firstStarted = Completer(); + final secondStarted = Completer(); + final secondCompleted = Completer(); + final _firstResponse = Completer>(); + final _secondResponse = Completer>(); + final List> calls = []; + + void completeFirst(OpAck ack) => _firstResponse.complete([ack]); + + void completeSecond(OpAck ack) => _secondResponse.complete([ack]); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add(List.from(ops)); + if (calls.length == 1) { + firstStarted.complete(); + return _firstResponse.future; + } + secondStarted.complete(); + final result = await _secondResponse.future; + secondCompleted.complete(); + return result; + } +} + +/// Holds the first record removal (an accepted op leaving the outbox) until +/// [releaseRemove]. +class _GatedRemoveStore extends MemoryDurableStrategyOutboxStore { + final removeStarted = Completer(); + final releaseRemove = Completer(); + + @override + Future remove(String storageKey) async { + if (!removeStarted.isCompleted) { + removeStarted.complete(); + await releaseRemove.future; + } + await super.remove(storageKey); + } +} + +/// Holds the write that promotes a successor (its record's pending op +/// changes and the successor goes) until [releasePromotion]. +class _GatedPromotionStore extends MemoryDurableStrategyOutboxStore { + final promotionStarted = Completer(); + final releasePromotion = Completer(); + + @override + Future put(DurableOutboxRecord record) async { + if (record.successorPending == null && !promotionStarted.isCompleted) { + promotionStarted.complete(); + await releasePromotion.future; + } + await super.put(record); + } +} + +class _OneShotAckFailureStore extends MemoryDurableStrategyOutboxStore { + _OneShotAckFailureStore({ + this.failRemove = false, + this.failAttentionPut = false, + }); + + bool failRemove; + bool failAttentionPut; + + @override + Future put(DurableOutboxRecord record) async { + if (failAttentionPut && record.status == DurableOutboxStatus.attention) { + failAttentionPut = false; + throw StateError('attention write failed'); + } + await super.put(record); + } + + @override + Future remove(String storageKey) async { + if (failRemove) { + failRemove = false; + throw StateError('accepted ack removal failed'); + } + await super.remove(storageKey); + } +} + +class _FirstPutFailureStore extends MemoryDurableStrategyOutboxStore { + var failNextPut = true; + + @override + Future put(DurableOutboxRecord record) async { + if (failNextPut) { + failNextPut = false; + throw StateError('initial write failed'); + } + await super.put(record); + } +} + +class _OversizedParkingFailureStore extends MemoryDurableStrategyOutboxStore { + _OversizedParkingFailureStore({ + this.dropBeforeThrow = false, + this.failRemove = false, + }); + + final bool dropBeforeThrow; + final bool failRemove; + var attentionWrites = 0; + var removalAttempts = 0; + + @override + Future put(DurableOutboxRecord record) async { + if (record.status == DurableOutboxStatus.attention && + cloudOperationExceedsPolicy(record.pending.op)) { + attentionWrites += 1; + if (dropBeforeThrow) values.remove(record.storageKey); + throw StateError('oversized attention write failed'); + } + await super.put(record); + } + + @override + Future remove(String storageKey) async { + removalAttempts += 1; + if (failRemove) throw StateError('uncertain removal failed'); + await super.remove(storageKey); + } +} + +class _FailingSelectedRemovalStore extends MemoryDurableStrategyOutboxStore { + String? failStorageKey; + + @override + Future remove(String storageKey) async { + if (storageKey == failStorageKey) { + throw StateError('selected removal failed'); + } + await super.remove(storageKey); + } +} + +class _UnusedTransport implements ConvexTransport { + @override + Future action(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future mutation(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Future query(String name, ConvexObject args) => + throw UnimplementedError(); + + @override + Stream subscribe(String name, ConvexObject args) => + throw UnimplementedError(); +} + +class _BlockingStore extends MemoryDurableStrategyOutboxStore { + final allowWrite = Completer(); + + @override + Future put(DurableOutboxRecord record) async { + await allowWrite.future; + await super.put(record); + } +} + +class _BlockingReplacementStore extends MemoryDurableStrategyOutboxStore { + final allowReplacement = Completer(); + var _writes = 0; + + @override + Future put(DurableOutboxRecord record) async { + _writes += 1; + if (_writes == 2) { + await allowReplacement.future; + } + await super.put(record); + } +} diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart new file mode 100644 index 00000000..484da0f6 --- /dev/null +++ b/test/strategy_page_session_provider_test.dart @@ -0,0 +1,10225 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_lineup_rows.dart'; +import 'package:icarus/collab/canonical_json.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/const/drawing_element.dart'; +import 'package:icarus/const/traversal_speed.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/utilities.dart'; +import 'package:icarus/providers/ability_provider.dart'; +import 'package:icarus/providers/agent_provider.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/drawing_provider.dart'; +import 'package:icarus/providers/editor_operation_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/utility_provider.dart'; +import 'package:icarus/collab/cloud_payload_upgrade.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/migrations/paranoia_range_migration.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/const/weapons.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/lineup_conflicts_provider.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/action_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/providers/transition_provider.dart' + hide PageTransitionState; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:supabase_flutter/supabase_flutter.dart' show User; + +class _FakeRemoteEditorNotifier extends RemoteEditorSnapshotNotifier { + _FakeRemoteEditorNotifier( + this.initialSnapshot, { + Map? pageCatalog, + }) : pageCatalog = Map.from( + pageCatalog ?? + { + if (initialSnapshot.activePage != null) + initialSnapshot.activePage!.page.publicId: + initialSnapshot.activePage!, + }, + ); + + RemoteEditorSnapshot initialSnapshot; + final Map pageCatalog; + int refreshCount = 0; + final List selectedPageIds = []; + String? failingPageId; + + @override + Future build() async => initialSnapshot; + + void setSnapshot(RemoteEditorSnapshot snapshot) { + initialSnapshot = snapshot; + final active = snapshot.activePage; + if (active != null) pageCatalog[active.page.publicId] = active; + state = AsyncData(snapshot); + } + + /// Runs as each page is asked for, before it is read. + void Function(String? pagePublicId)? onSetActivePage; + + @override + Future setActivePage(String? pagePublicId) async { + selectedPageIds.add(pagePublicId); + onSetActivePage?.call(pagePublicId); + if (pagePublicId == failingPageId) { + throw StateError('Failed to load $pagePublicId'); + } + final current = state.valueOrNull ?? initialSnapshot; + final page = pagePublicId == null ? null : pageCatalog[pagePublicId]; + state = AsyncData(RemoteEditorSnapshot( + shell: current.shell, + activePage: page, + )); + return page; + } + + /// While set, a refresh reads nothing, as a read refused or cut off. + bool readFails = false; + + /// While set, a refresh reads nothing when this holds for its count. + bool Function(int refreshCount)? readFailsWhen; + + /// While set, the next refresh waits for it, then reads [initialSnapshot] + /// as it is by then: a read still on its way. + Completer? refreshGate; + + /// While set, only a refresh made while this holds takes [refreshGate]. + bool Function()? gateWhen; + + @override + Future refresh() async { + refreshCount += 1; + final count = refreshCount; + final gate = (gateWhen?.call() ?? true) ? refreshGate : null; + if (gate != null) refreshGate = null; + await gate?.future; + final fails = readFails || (readFailsWhen?.call(count) ?? false); + state = fails ? const AsyncData(null) : AsyncData(initialSnapshot); + } + + @override + Future showRestoredPage(String pagePublicId) async { + selectedPageIds.add(pagePublicId); + await refresh(); + } + + /// A live read refused for auth: no snapshot until a later read works. + void failRead() => state = const AsyncData(null); +} + +class _FakeStrategyOpQueueNotifier extends StrategyOpQueueNotifier { + _FakeStrategyOpQueueNotifier({this.blockFlush = false}); + + final bool blockFlush; + bool failDiscard = false; + + /// While set, page writes wait on it before publishing, like the real + /// queue's durable write. + Completer? writeGate; + int flushNowCount = 0; + + /// Plays the server for one flush, like acking what it accepts. + FutureOr Function()? onFlush; + + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'test-client', + durableLoaded: true, + ); + + @override + void setActiveStrategy( + String? strategyPublicId, { + required String? accountId, + }) {} + + @override + Future syncDesiredGenericOp({ + required EntitySyncKey entityKey, + required StrategyOp? desiredOp, + bool flushImmediately = false, + }) async { + final queued = Map.from( + state.queuedByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + if (desiredOp != null && + state.attentionByEntityKey.containsKey(entityKey)) { + _keepBehindRefusal(successors, entityKey, desiredOp); + state = state.copyWith(successorByEntityKey: successors); + return; + } + if (desiredOp == null) { + // Like the real queue: nothing changed, nothing published. + if (!queued.containsKey(entityKey)) return; + queued.remove(entityKey); + } else { + queued[entityKey] = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp(op: desiredOp, clientId: 'test-client'), + ); + } + state = state.copyWith(queuedByEntityKey: queued); + } + + @override + Future syncDesiredOpsForPage({ + required String pageId, + required Map desiredOpsByEntityKey, + bool clearMissing = true, + bool flushImmediately = false, + }) async { + final gate = writeGate; + if (gate != null) await gate.future; + final queued = Map.from( + state.queuedByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + if (clearMissing) { + queued.removeWhere((key, _) => + key.pageId == pageId && !desiredOpsByEntityKey.containsKey(key)); + } + for (final entry in desiredOpsByEntityKey.entries) { + if (state.attentionByEntityKey.containsKey(entry.key)) { + _keepBehindRefusal(successors, entry.key, entry.value); + continue; + } + queued[entry.key] = QueuedEntityIntent( + entityKey: entry.key, + pending: PendingOp(op: entry.value, clientId: 'test-client'), + ); + } + state = state.copyWith( + queuedByEntityKey: queued, + successorByEntityKey: successors, + ); + } + + /// Like the real queue, keeps [desired] behind [key]'s refused op: work + /// the same as the refused op needs nothing behind it, and work the same + /// as the one already behind it leaves that one, op id and all. + void _keepBehindRefusal( + Map successors, + EntitySyncKey key, + StrategyOp desired, + ) { + bool same(StrategyOp left, StrategyOp right) => + left.kind == right.kind && + left.entityType == right.entityType && + left.entityPublicId == right.entityPublicId && + left.pagePublicId == right.pagePublicId && + cloudJsonEquivalent(left.payload, right.payload) && + left.sortIndex == right.sortIndex && + left.expectedRevision == right.expectedRevision; + if (same(state.attentionByEntityKey[key]!.pending.op, desired)) { + successors.remove(key); + return; + } + final behind = successors[key]?.pending.op; + if (behind != null && same(behind, desired)) return; + successors[key] = QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: desired, clientId: 'test-client'), + ); + } + + @override + Future flushNow() async { + flushNowCount += 1; + if (blockFlush) await Completer().future; + await onFlush?.call(); + } + + /// Lands every queued op, as the server accepting them all, each with + /// [ack] if given. + void ackQueued({OpAck Function(String opId)? ack}) { + final landed = state.queuedByEntityKey.values.toList(); + final acks = [ + for (final intent in landed) + AckedEntityIntent( + entityKey: intent.entityKey, + op: intent.pending.op, + ack: ack?.call(intent.pending.op.opId) ?? + AppliedOpAck(opId: intent.pending.op.opId, revision: 1), + ), + ]; + state = state.copyWith( + queuedByEntityKey: const {}, + lastAcks: [for (final acked in acks) acked.ack], + lastAckBatch: acks, + ); + } + + /// Takes the server's [acks] for the ops [sent], as the real queue does, + /// in one update: an op the server took leaves the queue, a refused one + /// waits in attention, and one it never answered (its send failed) stays + /// queued. + void answer(Map sent, List acks) { + final ackByOpId = {for (final ack in acks) ack.opId: ack}; + final queued = Map.from( + state.queuedByEntityKey, + ); + final attention = Map.from( + state.attentionByEntityKey, + ); + final answered = []; + for (final MapEntry(:key, value: op) in sent.entries) { + final ack = ackByOpId[op.opId]; + if (ack == null) continue; + if (queued[key]?.pending.op.opId == op.opId) queued.remove(key); + if (!ack.isAck) { + attention[key] = QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: op, clientId: 'test-client'), + ); + } + answered.add(AckedEntityIntent(entityKey: key, op: op, ack: ack)); + } + state = state.copyWith( + queuedByEntityKey: queued, + attentionByEntityKey: attention, + lastError: attention.isEmpty ? null : 'Some saved work needs attention.', + clearError: attention.isEmpty, + lastAcks: [for (final acked in answered) acked.ack], + lastAckBatch: answered, + ); + } + + @override + Future> discardRejected( + Set entityKeys, { + Map? onlyIf, + }) async { + if (failDiscard) return {}; + final attention = Map.from( + state.attentionByEntityKey, + ); + final successors = Map.from( + state.successorByEntityKey, + ); + final discarded = { + for (final key in attention.keys.toSet().intersection(entityKeys)) + if (onlyIf == null || + onlyIf[key] == + ( + attention[key]!.pending.op.opId, + successors[key]?.pending.op.opId, + )) + key, + }; + for (final key in discarded) { + attention.remove(key); + successors.remove(key); + } + state = state.copyWith( + attentionByEntityKey: attention, + successorByEntityKey: successors, + clearError: attention.isEmpty, + ); + return discarded; + } + + @override + Future discardDeletedPage(String pageId) async { + Map withoutPage( + Map intents) => + { + for (final entry in intents.entries) + if (entry.key.pageId != pageId) entry.key: entry.value, + }; + state = state.copyWith( + queuedByEntityKey: withoutPage(state.queuedByEntityKey), + pausedByEntityKey: withoutPage(state.pausedByEntityKey), + attentionByEntityKey: withoutPage(state.attentionByEntityKey), + successorByEntityKey: withoutPage(state.successorByEntityKey), + ); + return !state.inFlightByEntityKey.keys.any((key) => key.pageId == pageId); + } + + void reject(StrategyOp op) { + final key = EntitySyncKey.forStrategyOp(op)!; + final pending = PendingOp(op: op, clientId: 'test-client'); + final ack = RejectedOpAck( + opId: op.opId, + rejectionReason: OpRejectionReason.revisionMismatch, + current: const ElementCurrentSnapshot(revision: 2, value: {}), + ); + state = state.copyWith( + queuedByEntityKey: const {}, + attentionByEntityKey: { + key: QueuedEntityIntent(entityKey: key, pending: pending), + }, + isFlushing: false, + lastError: 'Some saved work needs attention.', + lastAcks: [ack], + lastAckBatch: [ + AckedEntityIntent(entityKey: key, op: op, ack: ack), + ], + ); + } + + /// Parks [op] after too many failed sends, as paused work. + void pause(StrategyOp op) { + final key = EntitySyncKey.forStrategyOp(op)!; + state = state.copyWith( + pausedByEntityKey: { + ...state.pausedByEntityKey, + key: QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: op, clientId: 'test-client'), + ), + }, + lastError: 'Some saved work is paused.', + ); + } + + /// Pages [retryRestoredPage] was asked for, with whether any of the + /// page's work was still in flight then, and whether any was refused. + final List<({String pageId, bool inFlight, bool refused})> + restoredPageRetries = []; + + @override + Future retryRestoredPage(String pageId) async { + bool onPage(EntitySyncKey key) => key.pageId == pageId; + restoredPageRetries.add(( + pageId: pageId, + inFlight: state.inFlightByEntityKey.keys.any(onPage), + refused: state.attentionByEntityKey.keys.any(onPage), + )); + } + + /// Live page sets [retryRestoredPages] was asked to re-send for. + final List> livePageRetries = []; + + @override + Future retryRestoredPages(Set livePageIds) async => + livePageRetries.add(livePageIds); + + void holdInFlight(EntitySyncKey key, StrategyOp op) { + state = state.copyWith( + inFlightByEntityKey: { + key: InFlightEntityIntent( + entityKey: key, + pending: PendingOp(op: op, clientId: 'test-client'), + sentAt: DateTime.utc(2026), + ), + }, + ); + } + + void clearInFlight() { + state = state.copyWith( + inFlightByEntityKey: const {}, + ); + } +} + +/// An outbox whose writes fail while [failPut] holds for the record, as a +/// full or locked disk. +class _FailingOutboxStore extends MemoryDurableStrategyOutboxStore { + bool Function(DurableOutboxRecord record)? failPut; + + /// Runs as each write starts, before it lands: while it is on its way. + void Function(DurableOutboxRecord record)? onPut; + + @override + Future put(DurableOutboxRecord record) async { + onPut?.call(record); + // A real write leaves the event loop; so does this one. + await Future.delayed(Duration.zero); + if (failPut?.call(record) ?? false) { + throw const FileSystemException('The disk is full.'); + } + await super.put(record); + } + + /// While set, a removal fails when this holds for its storage key. + bool Function(String storageKey)? failRemove; + + /// Runs as each removal starts, before it lands. + void Function(String storageKey)? onRemove; + + @override + Future remove(String storageKey) async { + onRemove?.call(storageKey); + await Future.delayed(Duration.zero); + if (failRemove?.call(storageKey) ?? false) { + throw const FileSystemException('The disk is locked.'); + } + await super.remove(storageKey); + } +} + +/// Plays the server's page restore: [onRestore] runs for each call, and +/// may throw as the server would. +class _RestoringRepository implements ConvexStrategyRepository { + _RestoringRepository(this.onRestore); + + FutureOr Function(String pagePublicId) onRestore; + final List restoredPageIds = []; + + @override + Future restorePage({ + required String strategyPublicId, + required String pagePublicId, + }) async { + restoredPageIds.add(pagePublicId); + await onRestore(pagePublicId); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Plays the server for the real queue: holds its first batch until +/// [landFirst], and accepts every later one at once. +class _HeldFirstBatchRepository implements ConvexStrategyRepository { + final List> calls = []; + final firstSent = Completer(); + final _firstAnswer = Completer(); + final _sent = StreamController.broadcast(); + + void landFirst() => _firstAnswer.complete(); + + /// The next element op for [elementId] sent after this call. + Future nextEditOf(String elementId) => _sent.stream + .firstWhere((op) => op.entityPublicId == elementId) + .timeout(const Duration(seconds: 5)); + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + calls.add(ops); + if (calls.length == 1) { + firstSent.complete(); + await _firstAnswer.future; + } else { + ops.forEach(_sent.add); + } + return [ + for (final op in ops) + AppliedOpAck(opId: op.opId, revision: (op.expectedRevision ?? 0) + 1), + ]; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _CloudReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _RecordingMediaQueue extends CloudMediaUploadQueueNotifier { + final List rechecked = []; + + @override + CloudMediaUploadQueueState build() => + const CloudMediaUploadQueueState(jobs: [], isProcessing: false); + + @override + Future recheckAfterDiscardedWork(String strategyPublicId) async { + rechecked.add(strategyPublicId); + } +} + +/// Whether this device can reach the server, for tests that go offline. +final _online = StateProvider((ref) => true); + +Future _settle() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +/// Waits, in real time, until [condition] holds. +Future _until(bool Function() condition) async { + final deadline = DateTime.now().add(const Duration(seconds: 5)); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('The condition never held.'); + } + await Future.delayed(const Duration(milliseconds: 10)); + } +} + +/// Plays the server for the real queue: [server] answers every batch, and +/// [afterBatch] runs once it has, before the answers reach the queue (as +/// the live read showing the batch can arrive before its answers). +class _ServerRepository implements ConvexStrategyRepository { + _ServerRepository(this.server, {required this.afterBatch}); + + final _FakeServer server; + final void Function() afterBatch; + final List> batches = []; + + /// While set, a batch waits for it before it reaches the server. + Completer? hold; + + /// While set, the next batch reaches the server, then this runs and the + /// answer is lost on its way back: the send fails as if offline. + void Function()? loseNextAnswer; + + /// While set, an op it answers for never reaches [server]; that answer + /// is returned instead, as a validation the server fails. + OpAck? Function(StrategyOp op)? refuse; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + batches.add(ops); + await hold?.future; + final acks = [ + for (final op in ops) refuse?.call(op) ?? server.apply(op), + ]; + if (loseNextAnswer case final lose?) { + loseNextAnswer = null; + lose(); + throw const SocketException('The answer was lost.'); + } + afterBatch(); + return acks; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +RemotePage _page(String id, int index, + {int revision = 1, String? name, bool isAttack = true}) { + final now = DateTime.utc(2026); + return RemotePage( + publicId: id, + strategyPublicId: 'cloud-strategy', + name: name ?? 'Page ${index + 1}', + sortIndex: index, + isAttack: isAttack, + revision: revision, + createdAt: now, + updatedAt: now, + ); +} + +RemoteElement _textElement( + String pageId, + String id, + String value, { + int revision = 1, + int sortIndex = 0, + bool worldSized = false, + bool deleted = false, +}) { + final text = PlacedText(id: id, position: const Offset(10, 20))..text = value; + if (worldSized) text.markSizeAsWorld(); + final payload = Map.from(text.toJson()) + ..['elementType'] = 'text'; + return RemoteElement( + publicId: id, + strategyPublicId: 'cloud-strategy', + pagePublicId: pageId, + elementType: 'text', + payload: cloudElementPayload(kind: 'text', data: payload), + sortIndex: sortIndex, + revision: revision, + deleted: deleted, + ); +} + +RemotePageSnapshot _pageSnapshot( + RemotePage page, { + String? text, + int contentRevision = 1, + CloudPayload settings = const {}, + List? elements, + List lineups = const [], +}) { + final now = DateTime.utc(2026); + return RemotePageSnapshot( + page: page, + content: RemotePageContent( + settings: settings, + revision: contentRevision, + createdAt: now, + updatedAt: now, + ), + elements: elements ?? + (text == null + ? const [] + : [_textElement(page.publicId, 'text-${page.publicId}', text)]), + lineups: lineups, + assetsById: const {}, + ); +} + +Map _agentJson(String id, Offset position) => { + 'id': 'agent-$id', + 'isDeleted': false, + 'position': {'dx': position.dx, 'dy': position.dy}, + 'type': 'sova', + 'isAlly': true, + 'state': 'none', + 'kind': 'plain', + 'lineUpID': id, + }; + +Map _abilityJson( + String id, [ + Offset position = const Offset(30, 40), +]) => + { + 'id': 'ability-$id', + 'isDeleted': false, + 'data': {'type': 'sova', 'index': 2.0}, + 'position': {'dx': position.dx, 'dy': position.dy}, + 'isAlly': true, + 'rotation': 0, + 'length': 0, + 'lineUpID': id, + 'visualState': { + 'showRangeOutline': true, + 'showRangeFill': true, + 'showInnerOutline': true, + 'showInnerFill': true, + }, + 'armLengthsMeters': [10, 10, 10, 10], + }; + +/// Origin [id] as a lineup group row carries it. +Map _originJson( + String id, [ + Offset position = const Offset(10, 20), +]) => + {'id': id, 'agent': _agentJson(id, position)}; + +/// Landing [id] as a lineup group row carries it. +Map _landingJson( + String id, [ + Offset position = const Offset(30, 40), +]) => + {'id': id, 'ability': _abilityJson(id, position)}; + +/// Lineup [id], from origin [originId] to landing [landingId], as a lineup +/// group row carries it. +Map _linkJson( + String id, { + required String originId, + required String landingId, + String name = '', + String notes = 'remote lineup', +}) => + { + 'id': id, + 'originId': originId, + 'landingId': landingId, + 'name': name, + 'youtubeLink': '', + 'notes': notes, + 'images': [], + }; + +/// One lineup group as the server stores it: the row [id], holding the +/// group's [origins], [landings] and [links]. +RemoteLineup _groupRow( + String pageId, + String id, { + required List> origins, + required List> landings, + required List> links, + int revision = 1, + int sortIndex = 0, + bool deleted = false, +}) { + return RemoteLineup( + publicId: id, + strategyPublicId: 'cloud-strategy', + pagePublicId: pageId, + payload: cloudLineupsPayload({ + 'id': id, + 'origins': origins, + 'landings': landings, + 'links': links, + }), + sortIndex: sortIndex, + revision: revision, + deleted: deleted, + ); +} + +/// A lineup on spots of its own, alone in its group: the link `link-[id]` +/// from the origin [id] to the landing `landing-[id]`, in the group named +/// after it. +RemoteLineup _lineup( + String pageId, + String id, { + Offset agentPosition = const Offset(10, 20), + String linkName = '', + int revision = 1, + int sortIndex = 0, +}) { + return _groupRow( + pageId, + 'link-$id', + origins: [_originJson(id, agentPosition)], + landings: [_landingJson('landing-$id')], + links: [ + _linkJson('link-$id', + originId: id, landingId: 'landing-$id', name: linkName), + ], + revision: revision, + sortIndex: sortIndex, + ); +} + +/// The key of the group row [_lineup] builds for [id]. +EntitySyncKey _lineupKey(String pageId, String id) => + EntitySyncKey.lineup(pageId, 'link-$id'); + +/// The entries of lineup group [data] under [field] (`origins`, +/// `landings` or `links`), to read or to edit in place. +List _entries(Map data, String field) => + [for (final entry in data[field] as List) entry as Map]; + +/// Entry [id] of lineup group [data] under [field]. +Map _entry(Map data, String field, String id) => + _entries(data, field).singleWhere((entry) => entry['id'] == id); + +/// [edit] applied to every lineup in a group's data. +void Function(Map data) _eachLink( + void Function(Map link) edit, +) => + (data) => _entries(data, 'links').forEach(edit); + +/// [edit] applied to every origin's agent in a group's data. +void Function(Map data) _eachAgent( + void Function(Map agent) edit, +) => + (data) => [ + for (final origin in _entries(data, 'origins')) + origin['agent'] as Map, + ].forEach(edit); + +/// [edit] applied to every landing's ability in a group's data. +void Function(Map data) _eachAbility( + void Function(Map ability) edit, +) => + (data) => [ + for (final landing in _entries(data, 'landings')) + landing['ability'] as Map, + ].forEach(edit); + +/// Lineup [id] in the group [payload] carries. +Map _linkIn(CloudPayload? payload, String id) => + _entry(cloudPayloadData(payload!), 'links', id); + +/// The one lineup in the group [payload] carries. +Map _onlyLinkIn(CloudPayload? payload) => + _entries(cloudPayloadData(payload!), 'links').single; + +/// [row] as a teammate left it: its group data changed by [edit], one +/// revision on. +RemoteLineup _editedGroup( + RemoteLineup row, + void Function(Map data) edit, { + bool deleted = false, +}) { + final payload = jsonDecode(jsonEncode(row.payload)) as CloudPayload; + edit(payload['data'] as Map); + return RemoteLineup( + publicId: row.publicId, + strategyPublicId: row.strategyPublicId, + pagePublicId: row.pagePublicId, + payload: payload, + sortIndex: row.sortIndex, + revision: row.revision + 1, + deleted: deleted, + ); +} + +/// [row] as a teammate left it: its one lineup changed by [edit], one +/// revision on. +RemoteLineup _editedLineup(RemoteLineup row, void Function(Map link) edit) => + _editedGroup(row, (data) => edit(_entries(data, 'links').single)); + +/// What each of [rows] holds, by group id: its origin, landing and lineup +/// ids, in order. +Map>> _groupsOf( + Iterable rows) => + { + for (final row in rows) + row.publicId: { + for (final field in ['origins', 'landings', 'links']) + field: [ + for (final entry + in _entries(cloudPayloadData(row.payload), field)) + entry['id'], + ], + }, + }; + +/// Plays the server's element and lineup rows of one page as convex/ops.ts +/// keeps them (applyElementOp, applyLineupOp). +/// +/// Every row has its own revision, which each change it takes moves on by +/// one. A delete leaves a tombstone: still listed, deleted, one revision on. +/// A write expecting another revision than the row's is refused with the +/// row as it is. A patch or reorder of a tombstone is refused as `deleted`; +/// an add expecting the tombstone's revision brings the row back. A write +/// that would change nothing is a noop, whatever revision it expected. An op +/// sent again (its answer lost) is answered as a noop at the revision it +/// landed at, or refused again. While [recordsRevisions] is off, an op taken +/// is recorded with no revision (as events written before the server kept +/// one), and sent again it is answered as a noop with none. +class _FakeServer { + _FakeServer( + this.pageId, { + Iterable lineups = const [], + Iterable elements = const [], + }) { + for (final row in lineups) { + _lineups[row.publicId] = _Row.lineup(row); + } + for (final row in elements) { + _elements[row.publicId] = _Row.element(row); + } + } + + final String pageId; + final Map _lineups = {}; + final Map _elements = {}; + + /// The answer to each element and lineup op taken, by op id. + final Map _answers = {}; + + /// Whether an op taken is recorded with the revision it landed at. + bool recordsRevisions = true; + + /// Ops taken while [recordsRevisions] was off. + final Set _unrecorded = {}; + + /// Every lineup op this server answered, in order. + final List received = []; + + /// Every lineup row, tombstones included, in stored order. + List get rows => [ + for (final MapEntry(:key, :value) in _sorted(_lineups)) + RemoteLineup( + publicId: key, + strategyPublicId: 'cloud-strategy', + pagePublicId: pageId, + payload: value.payload, + sortIndex: value.sortIndex, + revision: value.revision, + deleted: value.deleted, + ), + ]; + + /// The lineup rows lineups are drawn from. + List get liveRows => [ + for (final row in rows) + if (!row.deleted) row, + ]; + + RemoteLineup row(String id) => rows.singleWhere((row) => row.publicId == id); + + /// Every element row, tombstones included, in stored order. + List get elements => [ + for (final MapEntry(:key, :value) in _sorted(_elements)) + RemoteElement( + publicId: key, + strategyPublicId: 'cloud-strategy', + pagePublicId: pageId, + elementType: value.elementType!, + payload: value.payload, + sortIndex: value.sortIndex, + revision: value.revision, + deleted: value.deleted, + ), + ]; + + RemoteElement element(String id) => + elements.singleWhere((element) => element.publicId == id); + + /// The server's answer to each of [ops], applied in order. An op on + /// anything but an element or a lineup is applied as sent. + List applyBatch(Iterable ops) => + [for (final op in ops) apply(op)]; + + OpAck apply(StrategyOp op) { + final (rows, change) = switch (op) { + LineupAddOp(:final payload, :final sortIndex) => ( + _lineups, + (kind: StrategyOpKind.add, payload: payload, sortIndex: sortIndex), + ), + LineupPatchOp(:final payload, :final sortIndex) => ( + _lineups, + (kind: StrategyOpKind.patch, payload: payload, sortIndex: sortIndex), + ), + LineupReorderOp(:final sortIndex) => ( + _lineups, + (kind: StrategyOpKind.reorder, payload: null, sortIndex: sortIndex), + ), + LineupDeleteOp() => ( + _lineups, + (kind: StrategyOpKind.delete, payload: null, sortIndex: null), + ), + ElementAddOp(:final payload, :final sortIndex) => ( + _elements, + (kind: StrategyOpKind.add, payload: payload, sortIndex: sortIndex), + ), + ElementPatchOp(:final payload, :final sortIndex) => ( + _elements, + (kind: StrategyOpKind.patch, payload: payload, sortIndex: sortIndex), + ), + ElementReorderOp(:final sortIndex) => ( + _elements, + (kind: StrategyOpKind.reorder, payload: null, sortIndex: sortIndex), + ), + ElementDeleteOp() => ( + _elements, + (kind: StrategyOpKind.delete, payload: null, sortIndex: null), + ), + _ => (null, null), + }; + if (rows == null || change == null) { + return AppliedOpAck( + opId: op.opId, + revision: (op.expectedRevision ?? 0) + 1, + ); + } + if (identical(rows, _lineups)) received.add(op); + final id = op.entityPublicId!; + final existing = rows[id]; + OpAck refused(OpRejectionReason reason) => RejectedOpAck( + opId: op.opId, + rejectionReason: reason, + current: existing == null + ? null + : identical(rows, _lineups) + ? LineupCurrentSnapshot( + revision: existing.revision, + value: existing.payload, + ) + : ElementCurrentSnapshot( + revision: existing.revision, + value: existing.payload, + ), + ); + OpAck? stale() => op.expectedRevision == null + ? refused(OpRejectionReason.missingExpectedRevision) + : op.expectedRevision != existing!.revision + ? refused(OpRejectionReason.revisionMismatch) + : null; + OpAck store(CloudPayload payload, int sortIndex, {bool deleted = false}) { + final revision = (existing?.revision ?? 0) + 1; + rows[id] = _Row( + payload: payload, + sortIndex: sortIndex, + revision: revision, + deleted: deleted, + elementType: identical(rows, _lineups) + ? null + : existing?.elementType ?? payload['kind'] as String, + ); + return AppliedOpAck(opId: op.opId, revision: revision); + } + + OpAck noop() => + NoopOpAck(opId: op.opId, currentRevision: existing?.revision); + + // An op already answered is answered again as it was: an accepted one + // as a noop at the revision it landed at, not the row's latest. + switch (_answers[op.opId]) { + case RejectedOpAck(:final rejectionReason): + return refused(rejectionReason); + case final OpAck accepted?: + return NoopOpAck( + opId: op.opId, + currentRevision: + _unrecorded.contains(op.opId) ? null : accepted.appliedRevision, + ); + case null: + break; + } + + OpAck answer() { + switch (change.kind) { + case StrategyOpKind.add: + final payload = change.payload!; + final sortIndex = change.sortIndex!; + if (existing == null) return store(payload, sortIndex); + if (existing.deleted) return stale() ?? store(payload, sortIndex); + return _sameJson(existing.payload, payload) + ? noop() + : refused(OpRejectionReason.alreadyExists); + case StrategyOpKind.delete: + if (existing == null || existing.deleted) return noop(); + return stale() ?? + store(existing.payload, existing.sortIndex, deleted: true); + case StrategyOpKind.patch || StrategyOpKind.reorder: + if (existing == null) return refused(OpRejectionReason.notFound); + if (existing.deleted) return refused(OpRejectionReason.deleted); + final payload = change.payload ?? existing.payload; + final sortIndex = change.sortIndex ?? existing.sortIndex; + if (_sameJson(payload, existing.payload) && + sortIndex == existing.sortIndex) { + return noop(); + } + return stale() ?? store(payload, sortIndex); + } + } + + if (!recordsRevisions) _unrecorded.add(op.opId); + return _answers[op.opId] = answer(); + } + + /// A teammate's change to lineup group row [id] lands: [edit] changes its + /// data, one revision on. + void teammateEdit(String id, void Function(Map data) edit) { + final row = _lineups[id]!; + final payload = jsonDecode(jsonEncode(row.payload)) as CloudPayload; + edit(payload['data'] as Map); + if (_sameJson(payload, row.payload)) return; + _lineups[id] = row.next(payload: payload); + } + + /// A teammate's delete of lineup group row [id] lands. + void teammateDelete(String id) => + _lineups[id] = _lineups[id]!.next(deleted: true); + + /// A teammate's delete of element row [id] lands. + void teammateDeleteElement(String id) => + _elements[id] = _elements[id]!.next(deleted: true); + + static Iterable> _sorted(Map rows) => + rows.entries.toList() + ..sort((a, b) => a.value.sortIndex != b.value.sortIndex + ? a.value.sortIndex.compareTo(b.value.sortIndex) + : a.key.compareTo(b.key)); + + static bool _sameJson(Object? left, Object? right) => + canonicalCloudJsonEncode(jsonDecode(jsonEncode(left))) == + canonicalCloudJsonEncode(jsonDecode(jsonEncode(right))); +} + +/// One row as [_FakeServer] stores it. +class _Row { + _Row({ + required CloudPayload payload, + required this.sortIndex, + required this.revision, + required this.deleted, + this.elementType, + }) : payload = jsonDecode(jsonEncode(payload)) as CloudPayload; + + _Row.lineup(RemoteLineup row) + : this( + payload: row.payload, + sortIndex: row.sortIndex, + revision: row.revision, + deleted: row.deleted, + ); + + _Row.element(RemoteElement row) + : this( + payload: row.payload, + sortIndex: row.sortIndex, + revision: row.revision, + deleted: row.deleted, + elementType: row.elementType, + ); + + final CloudPayload payload; + final int sortIndex; + final int revision; + final bool deleted; + final String? elementType; + + /// This row one revision on, as a teammate's write leaves it. + _Row next({CloudPayload? payload, bool? deleted}) => _Row( + payload: payload ?? this.payload, + sortIndex: sortIndex, + revision: revision + 1, + deleted: deleted ?? this.deleted, + elementType: elementType, + ); +} + +RemoteEditorSnapshot _editorSnapshot({ + required List pages, + required RemotePageSnapshot activePage, + int shellRevision = 1, + String? mapData, + String? themeProfileId, + String role = 'owner', +}) { + final now = DateTime.utc(2026); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'cloud-strategy', + name: 'Cloud Strategy', + mapData: mapData ?? Maps.mapNames[MapValue.ascent]!, + revision: shellRevision, + createdAt: now, + updatedAt: now, + themeProfileId: themeProfileId, + role: role, + ), + pages: pages, + ), + activePage: activePage, + ); +} + +Future _cloudContainer({ + required _FakeRemoteEditorNotifier remote, + required _FakeStrategyOpQueueNotifier queue, + _RecordingMediaQueue? mediaQueue, + ConvexStrategyRepository? repository, +}) async { + final container = ProviderContainer(overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + strategyOpQueueProvider.overrideWith(() => queue), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudMediaUploadQueueProvider + .overrideWith(() => mediaQueue ?? _RecordingMediaQueue()), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + ]); + addTearDown(container.dispose); + container.read(strategyProvider.notifier).setFromState(const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + )); + container.listen(strategyPageSessionProvider, (_, __) {}); + await container.read(remoteEditorSnapshotProvider.future); + return container; +} + +Future _syncContainer({ + required _FakeRemoteEditorNotifier remote, + required _FakeStrategyOpQueueNotifier queue, +}) async { + final container = ProviderContainer(overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + strategyOpQueueProvider.overrideWith(() => queue), + ]); + addTearDown(container.dispose); + await container.read(remoteEditorSnapshotProvider.future); + return container; +} + +Future> _openStrategyBox() async { + final temp = await Directory.systemTemp.createTemp('icarus-page-session-'); + Hive.init(temp.path); + if (!Hive.isAdapterRegistered(9)) registerIcarusAdapters(Hive); + final box = await Hive.openBox(HiveBoxNames.strategiesBox); + addTearDown(() async { + await Hive.close(); + await temp.delete(recursive: true); + }); + return box; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => CoordinateSystem(playAreaSize: const Size(1920, 1080))); + + test('cloud strategy metadata patch carries the remote shell revision', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 17, + mapData: Maps.mapNames[MapValue.haven], + themeProfileId: 'remote-theme', + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + + await container + .read(strategyProvider.notifier) + .notifyCloudStrategyMutation(); + + final op = container.read(strategyOpQueueProvider).pending.single.op; + expect(op.entityType, StrategyOpEntityType.strategy); + expect(op.expectedRevision, 17); + expect(op.toConvexJson()['expectedStrategyRevision'], 17); + expect( + op.payload, + containsPair('mapData', Maps.mapNames[MapValue.ascent]), + ); + expect(op.payload, containsPair('clearThemeProfileId', true)); + }); + + test('cloud page reorder is persisted as a page descriptor op', () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + shellRevision: 17, + )), + queue: queue, + ); + + await container.read(strategyProvider.notifier).reorderPage(0, 2); + + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.reorder); + expect(pending.op.entityPublicId, 'page-1'); + expect(pending.op.sortIndex, 1); + expect(pending.op.expectedRevision, 17); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-1')); + expect(queue.flushNowCount, 1); + }); + + test('cloud page add is persisted with its descriptor and content', () async { + final page = _page('page-1', 0); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 8, + )), + queue: queue, + ); + + await container.read(strategyProvider.notifier).addPage('Execute'); + + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.add); + expect(pending.op.entityPublicId, isNotEmpty); + expect(pending.op.sortIndex, 1); + expect(pending.op.expectedRevision, 8); + expect(pending.op.payload, { + 'name': 'Execute', + 'isAutoNamed': false, + 'isAttack': true, + 'settings': container.read(strategySettingsProvider).toJson(), + }); + expect( + intent.key, + EntitySyncKey.pageDescriptor(pending.op.entityPublicId!), + ); + expect(queue.flushNowCount, 1); + }); + + test('cloud page rename is persisted with the page revision', () async { + final page = _page('page-1', 0, revision: 6); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, + ); + + await container.read(strategyProvider.notifier).renamePage( + 'page-1', + ' Retake ', + ); + + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.patch); + expect(pending.op.entityPublicId, 'page-1'); + expect(pending.op.payload, { + 'name': 'Retake', + 'isAutoNamed': false, + }); + expect(pending.op.expectedRevision, 6); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-1')); + expect(queue.flushNowCount, 1); + }); + + test('a cloud page delete reports whether the server took it', () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + Future open(_FakeStrategyOpQueueNotifier queue) => + _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + )), + queue: queue, + ); + + // No answer from the server: nothing to undo, so no undo is offered. + final unanswered = await open(_FakeStrategyOpQueueNotifier()); + expect( + await unanswered.read(strategyProvider.notifier).deletePage('page-2'), + isFalse, + ); + + final queue = _FakeStrategyOpQueueNotifier(); + queue.onFlush = () async => queue.ackQueued(); + final accepted = await open(queue); + expect( + await accepted.read(strategyProvider.notifier).deletePage('page-2'), + isTrue, + ); + + // A teammate deleted it first: this delete lands as a no-op, so there is + // nothing of this user's to undo. + final noopQueue = _FakeStrategyOpQueueNotifier(); + noopQueue.onFlush = + () async => noopQueue.ackQueued(ack: (opId) => NoopOpAck(opId: opId)); + final alreadyGone = await open(noopQueue); + expect( + await alreadyGone.read(strategyProvider.notifier).deletePage('page-2'), + isFalse, + ); + }); + + QueuedEntityIntent refusedOnPage2() => const QueuedEntityIntent( + entityKey: EntitySyncKey.element('page-2', 'text-2'), + pending: PendingOp( + op: ElementDeleteOp( + opId: 'refused-op', + pagePublicId: 'page-2', + elementPublicId: 'text-2', + expectedElementRevision: 1, + ), + clientId: 'test-client', + ), + ); + + test('opening a strategy re-sends edits for pages that came back meanwhile', + () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + // Restored while the strategy was closed: already live on first load. + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + queue.state = queue.state.copyWith(attentionByEntityKey: { + const EntitySyncKey.element('page-2', 'text-2'): refusedOnPage2(), + }); + + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + await _settle(); + expect(queue.livePageRetries.last, contains('page-2')); + }); + + test('"Use cloud" is not overridden when its refresh finds the page back', + () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first], + activePage: _pageSnapshot(first), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + queue.state = queue.state.copyWith(attentionByEntityKey: { + const EntitySyncKey.element('page-2', 'text-2'): refusedOnPage2(), + }); + // A teammate restored page-2; "Use cloud"'s refresh is what shows it. + remote.initialSnapshot = _editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first, contentRevision: 2), + ); + + await session.useCloudVersionsForRejected(); + await _settle(); + expect(queue.livePageRetries, isEmpty); + }); + + test('edits refused for a deleted page go out again once it is live', + () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first], + activePage: _pageSnapshot(first), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + // An edit to page-2 was refused while page-2 was in the trash. + const refused = ElementDeleteOp( + opId: 'refused-op', + pagePublicId: 'page-2', + elementPublicId: 'text-2', + expectedElementRevision: 1, + ); + const key = EntitySyncKey.element('page-2', 'text-2'); + queue.state = queue.state.copyWith(attentionByEntityKey: { + key: const QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: refused, clientId: 'test-client'), + ), + }); + + // Still deleted: nothing to re-send. + remote.setSnapshot(_editorSnapshot( + pages: [first], + activePage: _pageSnapshot(first, contentRevision: 2), + )); + await _settle(); + expect(queue.livePageRetries, isEmpty); + + // Restored, say by a teammate: its refused edits go out again. + remote.setSnapshot(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first, contentRevision: 3), + )); + await _settle(); + expect(queue.livePageRetries.single, contains('page-2')); + }); + + test('cloud page delete is persisted with the shell revision', () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + shellRevision: 12, + )), + queue: queue, + ); + + await container.read(strategyProvider.notifier).deletePage('page-2'); + + final intent = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .single; + final pending = intent.value.pending; + expect(pending.op.entityType, StrategyOpEntityType.page); + expect(pending.op.kind, StrategyOpKind.delete); + expect(pending.op.entityPublicId, 'page-2'); + expect(pending.op.expectedRevision, 12); + expect(intent.key, const EntitySyncKey.pageDescriptor('page-2')); + expect(queue.flushNowCount, 1); + }); + + test('tombstone restore op carries the remote entity revision', () async { + final page = _page('page-1', 0); + final element = _textElement( + page.publicId, + 'restored-text', + 'deleted remotely', + revision: 4, + deleted: true, + ); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [element]), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: element.publicId, position: const Offset(10, 20)) + ..text = 'restored locally', + ]); + container.read(activePageLiveSyncProvider.notifier).markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, + ); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final op = desired![EntitySyncKey.element(page.publicId, element.publicId)]; + expect(op, isNotNull); + expect(op!.kind, StrategyOpKind.add); + expect(op.expectedRevision, 4); + expect(op.toConvexJson()['expectedElementRevision'], 4); + }); + + test('active page update rehydrates without a strategy revision change', + () async { + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + shellRevision: 4, + ); + final remote = _FakeRemoteEditorNotifier(before); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + expect(container.read(textProvider).single.text, 'before'); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + shellRevision: 4, + )); + await _settle(); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('failed animated page switch restores the previous idle page', () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final pageOneSnapshot = _pageSnapshot(pageOne, text: 'one'); + final pageTwoSnapshot = _pageSnapshot(pageTwo, text: 'two'); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [pageOne, pageTwo], + activePage: pageOneSnapshot, + ), + pageCatalog: { + pageOne.publicId: pageOneSnapshot, + pageTwo.publicId: pageTwoSnapshot, + }, + ); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + remote.failingPageId = pageTwo.publicId; + + await expectLater( + container + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + pageTwo.publicId, + direction: PageTransitionDirection.forward, + ), + throwsStateError, + ); + + final session = container.read(strategyPageSessionProvider); + expect(session.activePageId, pageOne.publicId); + expect(session.transitionState, PageTransitionState.idle); + expect(container.read(transitionProvider).active, isFalse); + expect(remote.selectedPageIds, [pageTwo.publicId, pageOne.publicId]); + expect( + container.read(activePageLiveSyncProvider).hydratedPageId, + pageOne.publicId, + ); + }); + + test('remote hydration waits until an unchanged text draft is dismissed', + () async { + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + ); + final remote = _FakeRemoteEditorNotifier(before); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + const textId = 'text-page-1'; + container.read(textDraftProvider.notifier).setDraft(textId, 'before'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + )); + await _settle(); + + expect(container.read(textProvider).single.text, 'before'); + expect(container.read(textDraftProvider)[textId], 'before'); + + container.read(textDraftProvider.notifier).clearDraft(textId); + await _settle(); + + expect(container.read(textProvider).single.text, 'after'); + }); + + test( + 'a streamed update lands while a stroke is drawn, and the stroke is kept', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + // The pen is down on the canvas, over no item. + container.read(editorPointersProvider.notifier) + ..markCanvas(1) + ..down(1); + final drawing = container.read(drawingProvider.notifier); + drawing.startFreeDrawing( + const Offset(10, 20), + CoordinateSystem.instance, + Colors.white, + 2, + false, + false, + false, + TraversalSpeedProfile.values.first); + final draft = container.read(drawingProvider).currentElement; + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + )); + await _settle(); + expect(container.read(drawingProvider).currentElement, same(draft)); + expect(container.read(textProvider).single.text, 'after'); + + drawing.finishFreeDrawing(const Offset(40, 50), CoordinateSystem.instance); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + expect(container.read(drawingProvider).elements.single.id, draft!.id); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((item) => item.op.entityPublicId), + contains(draft.id)); + expect(container.read(textProvider).single.text, 'after'); + }); + + test('a streamed update lands during lineup placement and keeps it', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(lineUpProvider.notifier).startFresh(); + final placement = container.read(lineUpProvider).placement; + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + )); + await _settle(); + expect(container.read(lineUpProvider).placement, same(placement)); + expect(container.read(textProvider).single.text, 'after'); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + + /// Page one on screen with a stroke finished after a teammate deleted it: + /// work live sync can no longer send anywhere. + Future< + ({ + ProviderContainer container, + _FakeRemoteEditorNotifier remote, + _FakeStrategyOpQueueNotifier queue, + RemotePage one, + RemotePage two, + String strokeId, + })> strokeOnDeletedPage({ + /// Clears the unsaved mark before the pointer lifts, as an unrelated op + /// landing does. + bool unrelatedAckFirst = false, + _RecordingMediaQueue? mediaQueue, + ConvexStrategyRepository? repository, + }) async { + final one = _page('page-1', 0, name: 'A exec'); + final two = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: _pageSnapshot(one, text: 'one'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: { + one.publicId: _pageSnapshot(one, text: 'one'), + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: remote, + queue: queue, + mediaQueue: mediaQueue, + repository: repository, + ); + container.read(strategyPageSessionProvider.notifier).pageWorkSettleTimeout = + const Duration(milliseconds: 100); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(editorPointersProvider.notifier) + ..markCanvas(1) + ..down(1); + container.read(drawingProvider.notifier).startFreeDrawing( + const Offset(10, 20), + CoordinateSystem.instance, + Colors.white, + 2, + false, + false, + false, + TraversalSpeedProfile.values.first); + final draft = container.read(drawingProvider).currentElement; + + remote.setSnapshot(_editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + )); + await _settle(); + // Replacing the page waits for the stroke in progress. + expect(container.read(drawingProvider).currentElement, same(draft)); + expect(container.read(textProvider).single.text, 'one'); + + container + .read(drawingProvider.notifier) + .finishFreeDrawing(const Offset(40, 50), CoordinateSystem.instance); + if (unrelatedAckFirst) { + await _settle(); + container.read(strategySaveStateProvider.notifier).markPersisted(); + } + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + return ( + container: container, + remote: remote, + queue: queue, + one: one, + two: two, + strokeId: draft!.id, + ); + } + + test( + 'a teammate deleting the page on screen says the unsaved stroke ' + 'cannot be saved', () async { + final (:container, :one, :strokeId, queue: _, remote: _, two: _) = + await strokeOnDeletedPage(); + + // The stroke can never be sent to a page the server no longer has. + // Before, the page stayed on screen with the sync button spinning for + // good; now the user is told, and nothing moves until they have read it. + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, (pageId: one.publicId, name: 'A exec')); + expect(session.activePageId, one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + expect( + container + .read(strategyOpQueueProvider) + .pending + .where((pending) => pending.op.pagePublicId == one.publicId), + isEmpty); + + // Leaving before then would drop the stroke unseen. + await container + .read(strategyPageSessionProvider.notifier) + .setActivePage('page-2'); + expect( + container.read(strategyPageSessionProvider).activePageId, one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + }); + + test('switching pages before the notice shows still tells the user first', + () async { + final (:container, :remote, :one, :strokeId, queue: _, two: _) = + await strokeOnDeletedPage(); + // Take back the notice: as if the finished stroke landed while the + // replacement page was still loading, before anything asked. + container.read(strategyPageSessionProvider.notifier).setStateForTest( + container + .read(strategyPageSessionProvider) + .copyWith(clearDeletedPage: true)); + // The tap on the page selector is still down when it switches. + container.read(editorPointersProvider.notifier).down(2); + + await container + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + 'page-2', + direction: PageTransitionDirection.forward, + ); + + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage?.pageId, one.publicId); + expect(session.activePageId, one.publicId); + expect(session.transitionState, PageTransitionState.idle); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + expect(remote.selectedPageIds, isNot(contains('page-2'))); + }); + + test('an unrelated op landing does not let the page go unseen', () async { + final (:container, :one, :strokeId, queue: _, remote: _, two: _) = + await strokeOnDeletedPage(unrelatedAckFirst: true); + + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage?.pageId, one.publicId); + expect(session.activePageId, one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + }); + + test("the user's own deletion of the page on screen says nothing", () async { + final one = _page('page-1', 0); + final two = _page('page-2', 1); + final loadedOne = _pageSnapshot( + one, + settings: StrategySettings().toJson(), + elements: const [], + ); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: loadedOne, + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: { + one.publicId: loadedOne, + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + // The delete has landed and the live read shows it before its ack. + queue.holdInFlight( + EntitySyncKey.pageDescriptor(one.publicId), + PageDeleteOp( + opId: 'delete-page', + pagePublicId: one.publicId, + expectedStrategyRevision: 1, + ), + ); + remote.setSnapshot(_editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + )); + await _settle(); + expect(container.read(strategyPageSessionProvider).deletedPage, isNull); + + queue.clearInFlight(); + await _settle(); + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, two.publicId); + }); + + for (final snapshotFirst in [false, true]) { + test( + "the user's own deletion says nothing over an earlier paused edit " + '(snapshot first: $snapshotFirst)', () async { + final one = _page('page-1', 0); + final two = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: _pageSnapshot(one, text: 'one'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: { + one.publicId: _pageSnapshot(one, text: 'one'), + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + // An earlier edit on the page that the server kept failing. + final paused = ElementPatchOp( + opId: 'paused-edit', + pagePublicId: one.publicId, + elementPublicId: 'text-${one.publicId}', + payload: const {'value': 'kept'}, + expectedElementRevision: 1, + ); + queue.pause(paused); + // The server accepts the delete, and the live read drops the page, + // before or after the ack arrives. + queue.onFlush = () async { + final deleting = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .values + .any((intent) => intent.pending.op is PageDeleteOp); + if (!deleting) return; + // The delete moves the strategy from revision 1 to 2. + void dropPage() => remote.setSnapshot(_editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + shellRevision: 2, + themeProfileId: + MapThemeProfilesProvider.immutableDefaultProfileId, + )); + if (snapshotFirst) { + dropPage(); + await _settle(); + queue.ackQueued(ack: (opId) => AppliedOpAck(opId: opId, revision: 2)); + } else { + queue.ackQueued(ack: (opId) => AppliedOpAck(opId: opId, revision: 2)); + dropPage(); + } + }; + + await container.read(strategyProvider.notifier).deletePage(one.publicId); + queue.onFlush = null; + await _settle(); + + // Before, the page read as deleted by a teammate and the switch the + // delete planned was refused. + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, two.publicId); + expect(container.read(textProvider).single.text, 'two'); + // The paused edit still shows in the sync status. + expect( + container.read(strategyOpQueueProvider).pausedByEntityKey.keys, + [EntitySyncKey.forStrategyOp(paused)], + ); + }); + } + + test('a deletion landing while a switch flushes still tells the user', + () async { + final one = _page('page-1', 0, name: 'A exec'); + final two = _page('page-2', 1); + final loadedOne = _pageSnapshot( + one, + settings: StrategySettings().toJson(), + elements: const [], + ); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: loadedOne, + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: { + one.publicId: loadedOne, + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(editorPointersProvider.notifier) + ..markCanvas(1) + ..down(1); + container.read(drawingProvider.notifier) + ..startFreeDrawing( + const Offset(10, 20), + CoordinateSystem.instance, + Colors.white, + 2, + false, + false, + false, + TraversalSpeedProfile.values.first) + ..finishFreeDrawing(const Offset(40, 50), CoordinateSystem.instance); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + final stroke = container.read(drawingProvider).elements.single.id; + // The switch sends the stroke; the teammate's delete gets there first. + queue.onFlush = () { + queue.onFlush = null; + remote.setSnapshot(_editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + )); + }; + + await container + .read(strategyPageSessionProvider.notifier) + .setActivePageAnimated( + two.publicId, + direction: PageTransitionDirection.forward, + ); + await _settle(); + + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, (pageId: one.publicId, name: 'A exec')); + expect(session.activePageId, one.publicId); + expect(session.transitionState, PageTransitionState.idle); + expect(container.read(drawingProvider).elements.map((d) => d.id), [stroke]); + }); + + test('reading the notice moves to a page that exists', () async { + final mediaQueue = _RecordingMediaQueue(); + final (:container, :queue, :one, :two, remote: _, strokeId: _) = + await strokeOnDeletedPage(mediaQueue: mediaQueue); + // Work queued before the deletion goes too. + await queue.syncDesiredGenericOp( + entityKey: EntitySyncKey.element(one.publicId, 'old'), + desiredOp: ElementDeleteOp( + opId: 'old-op', + pagePublicId: one.publicId, + elementPublicId: 'old', + expectedElementRevision: 1, + ), + ); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + await _settle(); + + expect(left, isTrue); + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, two.publicId); + expect(container.read(drawingProvider).elements, isEmpty); + expect(container.read(textProvider).single.text, 'two'); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + expect(container.read(strategySaveStateProvider).isDirty, isFalse); + // Images only the dropped work placed are checked before they upload. + expect(mediaQueue.rechecked, ['cloud-strategy']); + }); + + test("reading the notice moves on while another page's work waits", () async { + final (:container, :queue, :two, one: _, remote: _, strokeId: _) = + await strokeOnDeletedPage(); + // Another page's edit, waiting on the server (paused, say). + const otherKey = EntitySyncKey.element('page-3', 'other'); + await queue.syncDesiredGenericOp( + entityKey: otherKey, + desiredOp: const ElementDeleteOp( + opId: 'other-op', + pagePublicId: 'page-3', + elementPublicId: 'other', + expectedElementRevision: 1, + ), + ); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + await _settle(); + + expect(left, isTrue); + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, two.publicId); + expect(container.read(drawingProvider).elements, isEmpty); + expect(container.read(textProvider).single.text, 'two'); + // The deleted page's work is gone; the other page's still waits. + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.pagePublicId), + ['page-3'], + ); + }); + + test('reading the notice keeps a map change still on its way', () async { + final (:container, :queue, :two, one: _, remote: _, strokeId: _) = + await strokeOnDeletedPage(); + container.read(mapProvider.notifier).updateMap(MapValue.haven); + await queue.syncDesiredGenericOp( + entityKey: const EntitySyncKey.strategy(), + desiredOp: StrategyPatchOp( + opId: 'map-change', + expectedStrategyRevision: 1, + payload: {'mapData': Maps.mapNames[MapValue.haven]}, + ), + ); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + + expect(left, isTrue); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + expect(container.read(mapProvider).currentMap, MapValue.haven); + }); + + test('reading the notice reads the server again after a failed read', + () async { + final (:container, :remote, :two, one: _, queue: _, strokeId: _) = + await strokeOnDeletedPage(); + remote.failRead(); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + + expect(left, isTrue); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + }); + + test('leaving waits for work already sent for the page', () async { + final (:container, :queue, :one, :two, remote: _, strokeId: _) = + await strokeOnDeletedPage(); + final key = EntitySyncKey.element(one.publicId, 'sent'); + queue.holdInFlight( + key, + ElementDeleteOp( + opId: 'sent-op', + pagePublicId: one.publicId, + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + Future.delayed( + const Duration(milliseconds: 10), () => queue.clearInFlight()); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + await _settle(); + + expect(left, isTrue); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + }); + + test('work sent for the page that never comes back keeps the choice open', + () async { + final (:container, :queue, :one, :strokeId, remote: _, two: _) = + await strokeOnDeletedPage(); + queue.holdInFlight( + EntitySyncKey.element(one.publicId, 'sent'), + ElementDeleteOp( + opId: 'sent-op', + pagePublicId: one.publicId, + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + + expect(left, isFalse); + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + }); + + /// The server's copy once page one is restored: back in the shell, and + /// what fresh reads return. + void serverRestoresPageOne( + _FakeRemoteEditorNotifier remote, + RemotePage one, + RemotePage two, + ) { + final restored = _pageSnapshot(one, text: 'one'); + remote.pageCatalog[one.publicId] = restored; + remote.initialSnapshot = _editorSnapshot( + pages: [one, two], + activePage: restored, + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ); + } + + bool strokeIsQueued(ProviderContainer container, String pageId, String id) => + container + .read(strategyOpQueueProvider) + .queuedByEntityKey[EntitySyncKey.element(pageId, id)] + ?.pending + .op is ElementAddOp; + + test('restoring the deleted page saves the unsent stroke on it', () async { + late _FakeRemoteEditorNotifier server; + late RemotePage pageOne; + late RemotePage pageTwo; + final repository = _RestoringRepository( + (_) => serverRestoresPageOne(server, pageOne, pageTwo)); + final (:container, :remote, :queue, :one, :two, :strokeId) = + await strokeOnDeletedPage(repository: repository); + (server, pageOne, pageTwo) = (remote, one, two); + final flushesBefore = queue.flushNowCount; + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + await _settle(); + + expect(outcome, DeletedPageRestore.restored); + expect(repository.restoredPageIds, [one.publicId]); + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + // The stroke is queued against the restored page and sent now, with + // the changes the server refused while the page was in its trash. + expect(strokeIsQueued(container, one.publicId, strokeId), isTrue); + expect(queue.flushNowCount, greaterThan(flushesBefore)); + expect(queue.restoredPageRetries, + [(pageId: one.publicId, inFlight: false, refused: false)]); + // The live read is back on the page on screen. + expect( + container + .read(remoteEditorSnapshotProvider) + .valueOrNull + ?.activePage + ?.page + .publicId, + one.publicId, + ); + }); + + test( + 'a page that can no longer be restored says so, saves nothing, and ' + 'discarding still works', () async { + final repository = _RestoringRepository( + (_) => throw const ConvexFunctionException( + code: ConvexErrorCode.notFound, + rawCode: 'NOT_FOUND', + message: 'Page not found: page-1', + ), + ); + final (:container, :queue, :one, :two, :strokeId, remote: _) = + await strokeOnDeletedPage(repository: repository); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(outcome, DeletedPageRestore.gone); + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + expect(strokeIsQueued(container, one.publicId, strokeId), isFalse); + expect(queue.restoredPageRetries, isEmpty); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + await _settle(); + expect(left, isTrue); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + expect(container.read(drawingProvider).elements, isEmpty); + }); + + test('a restore that fails changes nothing and can be tried again', () async { + late _FakeRemoteEditorNotifier server; + late RemotePage pageOne; + late RemotePage pageTwo; + var offline = true; + final repository = _RestoringRepository((_) { + if (offline) throw StateError('Cloud connection is offline.'); + serverRestoresPageOne(server, pageOne, pageTwo); + }); + final (:container, :remote, :queue, :one, :two, :strokeId) = + await strokeOnDeletedPage(repository: repository); + (server, pageOne, pageTwo) = (remote, one, two); + + final failed = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(failed, DeletedPageRestore.failed); + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [strokeId]); + expect(strokeIsQueued(container, one.publicId, strokeId), isFalse); + expect(queue.restoredPageRetries, isEmpty); + + offline = false; + final restored = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + await _settle(); + expect(restored, DeletedPageRestore.restored); + expect(strokeIsQueued(container, one.publicId, strokeId), isTrue); + }); + + test('a restored page that cannot be read yet keeps the notice', () async { + // The server restores it, but the read that follows still lacks it. + final repository = _RestoringRepository((_) {}); + final (:container, :queue, :one, :strokeId, remote: _, two: _) = + await strokeOnDeletedPage(repository: repository); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(outcome, DeletedPageRestore.failed); + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + expect(strokeIsQueued(container, one.publicId, strokeId), isFalse); + expect(queue.restoredPageRetries, isEmpty); + }); + + test( + 'a change still on its way when the page is restored is answered ' + 'before refused changes are sent again', () async { + late _FakeRemoteEditorNotifier server; + late RemotePage pageOne; + late RemotePage pageTwo; + final repository = _RestoringRepository( + (_) => serverRestoresPageOne(server, pageOne, pageTwo)); + final (:container, :remote, :queue, :one, :two, strokeId: _) = + await strokeOnDeletedPage(repository: repository); + (server, pageOne, pageTwo) = (remote, one, two); + queue.holdInFlight( + EntitySyncKey.element(one.publicId, 'sent'), + ElementDeleteOp( + opId: 'sent-op', + pagePublicId: one.publicId, + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + Future.delayed( + const Duration(milliseconds: 10), () => queue.clearInFlight()); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(outcome, DeletedPageRestore.restored); + expect(queue.restoredPageRetries, + [(pageId: one.publicId, inFlight: false, refused: false)]); + }); + + test( + 'a send whose answer was lost, refused on replay after the restore, ' + 'is sent again', () async { + late _FakeRemoteEditorNotifier server; + late RemotePage pageOne; + late RemotePage pageTwo; + final repository = _RestoringRepository( + (_) => serverRestoresPageOne(server, pageOne, pageTwo)); + final (:container, :remote, :queue, :one, :two, strokeId: _) = + await strokeOnDeletedPage(repository: repository); + (server, pageOne, pageTwo) = (remote, one, two); + // Sent while the page was in the trash; the server refused it, but the + // answer was lost, so it waits to be replayed under its own op id. + final lost = EntitySyncKey.element(one.publicId, 'lost'); + await queue.syncDesiredGenericOp( + entityKey: lost, + desiredOp: ElementDeleteOp( + opId: 'lost-op', + pagePublicId: one.publicId, + elementPublicId: 'lost', + expectedElementRevision: 1, + ), + ); + // The flush replays it and gets the recorded refusal; the rest lands. + queue.onFlush = () { + final replayed = queue.state.queuedByEntityKey[lost]; + if (replayed == null) return; + queue.state = queue.state.copyWith( + queuedByEntityKey: {...queue.state.queuedByEntityKey}..remove(lost), + attentionByEntityKey: {lost: replayed}, + ); + queue.ackQueued(); + }; + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(outcome, DeletedPageRestore.restored); + // Once before the flush, and again for the refusal the replay brought. + expect(queue.restoredPageRetries, [ + (pageId: one.publicId, inFlight: false, refused: false), + (pageId: one.publicId, inFlight: false, refused: true), + ]); + }); + + /// Page two on screen; page one, deleted earlier, is in the server's + /// trash. + Future< + ({ + ProviderContainer container, + _FakeStrategyOpQueueNotifier queue, + })> pageInTrash(_RestoringRepository repository) async { + final one = _page('page-1', 0, name: 'A exec'); + final two = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: {two.publicId: _pageSnapshot(two, text: 'two')}); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: remote, + queue: queue, + repository: repository, + ); + container.read(strategyPageSessionProvider.notifier).pageWorkSettleTimeout = + const Duration(milliseconds: 100); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + expect(one.publicId, 'page-1'); + return (container: container, queue: queue); + } + + test( + 'restoring from Recently deleted sends again what the server refused ' + 'while the page was in the trash, once its sends have answered', + () async { + final repository = _RestoringRepository((_) {}); + final (:container, :queue) = await pageInTrash(repository); + queue.holdInFlight( + const EntitySyncKey.element('page-1', 'sent'), + const ElementDeleteOp( + opId: 'sent-op', + pagePublicId: 'page-1', + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + Future.delayed( + const Duration(milliseconds: 10), () => queue.clearInFlight()); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restorePageFromTrash('page-1'); + + expect(outcome, DeletedPageRestore.restored); + expect(repository.restoredPageIds, ['page-1']); + // Once its sends answered, and again once nothing of it is queued. + expect(queue.restoredPageRetries, [ + (pageId: 'page-1', inFlight: false, refused: false), + (pageId: 'page-1', inFlight: false, refused: false), + ]); + // The page on screen stays; the restored one comes back in the list. + expect(container.read(strategyPageSessionProvider).activePageId, 'page-2'); + }); + + test( + 'a send whose answer was lost, refused on replay after a restore from ' + 'Recently deleted, is sent again', () async { + final repository = _RestoringRepository((_) {}); + final (:container, :queue) = await pageInTrash(repository); + // Sent while the page was in the trash; the server refused it, but the + // answer was lost, so it waits to be replayed under its own op id. + const lost = EntitySyncKey.element('page-1', 'lost'); + await queue.syncDesiredGenericOp( + entityKey: lost, + desiredOp: const ElementDeleteOp( + opId: 'lost-op', + pagePublicId: 'page-1', + elementPublicId: 'lost', + expectedElementRevision: 1, + ), + ); + // The replay brings back the refusal the server recorded. + Future.delayed(const Duration(milliseconds: 10), () { + final replayed = queue.state.queuedByEntityKey[lost]!; + queue.state = queue.state.copyWith( + queuedByEntityKey: {...queue.state.queuedByEntityKey}..remove(lost), + attentionByEntityKey: {lost: replayed}, + ); + }); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restorePageFromTrash('page-1'); + + expect(outcome, DeletedPageRestore.restored); + expect(queue.restoredPageRetries, [ + (pageId: 'page-1', inFlight: false, refused: false), + (pageId: 'page-1', inFlight: false, refused: true), + ]); + }); + + test( + 'a page restored from Recently deleted whose sends never answer is ' + 'restored, its refusals left to Keep mine', () async { + final repository = _RestoringRepository((_) {}); + final (:container, :queue) = await pageInTrash(repository); + queue.holdInFlight( + const EntitySyncKey.element('page-1', 'sent'), + const ElementDeleteOp( + opId: 'sent-op', + pagePublicId: 'page-1', + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restorePageFromTrash('page-1'); + + expect(outcome, DeletedPageRestore.restored); + expect(queue.restoredPageRetries, isEmpty); + }); + + test( + 'restoring from Recently deleted says when the page is gone, and when ' + 'it failed', () async { + var gone = true; + final repository = _RestoringRepository((_) { + if (gone) { + throw const ConvexFunctionException( + code: ConvexErrorCode.notFound, + rawCode: 'NOT_FOUND', + message: 'Page not found: page-1', + ); + } + throw StateError('Cloud connection is offline.'); + }); + final (:container, :queue) = await pageInTrash(repository); + final session = container.read(strategyPageSessionProvider.notifier); + + expect( + await session.restorePageFromTrash('page-1'), DeletedPageRestore.gone); + gone = false; + expect(await session.restorePageFromTrash('page-1'), + DeletedPageRestore.failed); + expect(queue.restoredPageRetries, isEmpty); + }); + + test('a restore whose earlier sends never answer keeps the notice', () async { + late _FakeRemoteEditorNotifier server; + late RemotePage pageOne; + late RemotePage pageTwo; + final repository = _RestoringRepository( + (_) => serverRestoresPageOne(server, pageOne, pageTwo)); + final (:container, :remote, :queue, :one, :two, strokeId: _) = + await strokeOnDeletedPage(repository: repository); + (server, pageOne, pageTwo) = (remote, one, two); + // Sent before the delete landed; its refusal may still be on its way. + queue.holdInFlight( + EntitySyncKey.element(one.publicId, 'sent'), + ElementDeleteOp( + opId: 'sent-op', + pagePublicId: one.publicId, + elementPublicId: 'sent', + expectedElementRevision: 1, + ), + ); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .restoreDeletedPage(); + + expect(outcome, DeletedPageRestore.failed); + expect(queue.restoredPageRetries, isEmpty); + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + }); + + test('discarding once the page is back shows its server copy', () async { + final (:container, :remote, :one, :two, queue: _, strokeId: _) = + await strokeOnDeletedPage(); + // Restored meanwhile: by this device's restore whose read then failed, + // or by a teammate. + serverRestoresPageOne(remote, one, two); + + final left = await container + .read(strategyPageSessionProvider.notifier) + .leaveDeletedPage(); + await _settle(); + + expect(left, isTrue); + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, one.publicId); + expect(container.read(drawingProvider).elements, isEmpty); + expect(container.read(textProvider).single.text, 'one'); + }); + + // How the server answers this device's delete: applied at once; on a + // replay after its first answer was lost, before the restore; or only + // after the teammate's second delete, applied (published late) or on the + // replay. + for (final answer in [ + 'applied', + 'replayed', + 'applied last', + 'replayed last', + ]) { + test( + "a page this device deleted, restored, then deleted by a teammate " + 'still tells the user (delete $answer)', () async { + final one = _page('page-1', 0, name: 'A exec'); + final two = _page('page-2', 1); + final loadedOne = _pageSnapshot( + one, + settings: StrategySettings().toJson(), + elements: const [], + ); + final loadedTwo = _pageSnapshot( + two, + settings: StrategySettings().toJson(), + elements: const [], + ); + RemoteEditorSnapshot shell( + List pages, + RemotePageSnapshot on, { + int revision = 1, + }) => + _editorSnapshot( + pages: pages, + activePage: on, + shellRevision: revision, + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ); + final remote = _FakeRemoteEditorNotifier(shell([one, two], loadedOne), + pageCatalog: {one.publicId: loadedOne, two.publicId: loadedTwo}); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + // This device deletes page one; the server accepts it. + await queue.syncDesiredGenericOp( + entityKey: EntitySyncKey.pageDescriptor(one.publicId), + desiredOp: PageDeleteOp( + opId: 'delete-one', + pagePublicId: one.publicId, + expectedStrategyRevision: 1, + ), + ); + // A delete whose first answer was lost is answered on replay with a + // no-op at the strategy's revision by then. + if (answer == 'applied') { + queue.ackQueued(ack: (opId) => AppliedOpAck(opId: opId, revision: 2)); + } else if (answer == 'replayed') { + queue.ackQueued( + ack: (opId) => NoopOpAck(opId: opId, currentRevision: 3)); + } + remote.setSnapshot(shell([two], loadedTwo, revision: 2)); + await _settle(); + await _settle(); + // With its delete unanswered, the canvas waits on page one; otherwise + // it moves on, and comes back once a teammate restores the page. + final waiting = answer.endsWith('last'); + expect(container.read(strategyPageSessionProvider).activePageId, + waiting ? one.publicId : two.publicId); + remote.setSnapshot(shell([one, two], loadedTwo, revision: 3)); + await _settle(); + if (!waiting) await session.setActivePage(one.publicId); + await _settle(); + expect(container.read(strategyPageSessionProvider).activePageId, + one.publicId); + + // A teammate deletes it mid-stroke. + container.read(editorPointersProvider.notifier) + ..markCanvas(1) + ..down(1); + container.read(drawingProvider.notifier).startFreeDrawing( + const Offset(10, 20), + CoordinateSystem.instance, + Colors.white, + 2, + false, + false, + false, + TraversalSpeedProfile.values.first); + remote.setSnapshot(shell([two], loadedTwo, revision: 4)); + await _settle(); + container + .read(drawingProvider.notifier) + .finishFreeDrawing(const Offset(40, 50), CoordinateSystem.instance); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + final stroke = container.read(drawingProvider).elements.single.id; + if (waiting) { + queue.ackQueued( + ack: (opId) => answer == 'applied last' + ? AppliedOpAck(opId: opId, revision: 2) + : NoopOpAck(opId: opId, currentRevision: 4), + ); + await _settle(); + } + + // Before, the old delete still counted as this device's and the stroke + // could be dropped unseen. + expect(container.read(strategyPageSessionProvider).deletedPage?.pageId, + one.publicId); + await session.setActivePage(two.publicId); + expect(container.read(strategyPageSessionProvider).activePageId, + one.publicId); + expect( + container.read(drawingProvider).elements.map((d) => d.id), [stroke]); + }); + } + + test('a deleted page with nothing unsent gives way to one that exists', + () async { + final one = _page('page-1', 0); + final two = _page('page-2', 1); + // Page one as this client writes it, so loading it changes nothing. + final loadedOne = _pageSnapshot( + one, + settings: StrategySettings().toJson(), + elements: const [], + ); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: loadedOne, + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + ), + pageCatalog: { + one.publicId: loadedOne, + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(editorPointersProvider.notifier) + ..markCanvas(1) + ..down(1); + remote.setSnapshot(_editorSnapshot( + pages: [two], + activePage: _pageSnapshot(two, text: 'two'), + themeProfileId: MapThemeProfilesProvider.immutableDefaultProfileId, + )); + await _settle(); + // An edit that changed nothing still marks the page unsaved. + await container.read(strategyProvider.notifier).notifyCloudMutation(); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + await _settle(); + + final session = container.read(strategyPageSessionProvider); + expect(session.deletedPage, isNull); + expect(session.activePageId, two.publicId); + expect(container.read(textProvider).single.text, 'two'); + expect(container.read(strategySaveStateProvider).isDirty, isFalse); + }); + + test('an edit to a held lineup is checked against the version the user saw', + () async { + final page = _page('page-1', 0); + final row = _lineup(page.publicId, 'a'); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before', lineups: [row]), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'a') + ..down(1); + // A teammate's notes edit to the same lineup lands mid-drag. + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: + _pageSnapshot(page, text: 'after', contentRevision: 2, lineups: [ + _editedLineup(row, (link) => link['notes'] = 'teammate notes'), + ]), + )); + await _settle(); + expect(container.read(textProvider).single.text, 'after'); + expect(container.read(lineUpProvider).links.single.notes, 'remote lineup'); + container + .read(lineUpProvider.notifier) + .updateOriginAgentPosition('a', const Offset(400, 400)); + final ops = container + .read(activePageLiveSyncProvider.notifier) + .syncLocalPage( + strategyPublicId: 'cloud-strategy', pageId: page.publicId)!; + final op = ops[_lineupKey(page.publicId, 'a')]; + // Revision 1, not the teammate's 2: the server refuses it as a conflict + // instead of the move silently overwriting their notes. + expect(op, isA()); + expect(op!.expectedRevision, 1); + await _settle(); + }); + + group('stacking order across merges', () { + final page = _page('page-1', 0); + RemoteElement text(String id, int sortIndex, + {int revision = 1, bool deleted = false}) => + _textElement(page.publicId, id, id, + sortIndex: sortIndex, + revision: revision, + deleted: deleted, + worldSized: true); + RemoteEditorSnapshot snapshot(List elements, + {int contentRevision = 1}) => + _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, + contentRevision: contentRevision, elements: elements), + ); + + Future open(_FakeRemoteEditorNotifier remote) async { + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return container; + } + + Map elementOps(ProviderContainer container) { + final ops = container + .read(activePageLiveSyncProvider.notifier) + .syncLocalPage( + strategyPublicId: 'cloud-strategy', pageId: page.publicId)!; + return { + for (final entry in ops.entries) + if (entry.key.kind == EntitySyncKeyKind.element) + entry.key.entityId: entry.value, + }; + } + + test('an acked edit the canvas never showed is still taken', () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + expect(container.read(textProvider).single.text, 'a'); + // A patch restored from the outbox lands after the page was drawn. + final landed = _textElement(page.publicId, 'a', 'restored', + revision: 2, worldSized: true); + final op = ElementPatchOp( + opId: 'restored-patch', + pagePublicId: page.publicId, + elementPublicId: 'a', + expectedElementRevision: 1, + payload: landed.payload, + sortIndex: 0, + ); + remote.setSnapshot(snapshot([landed], contentRevision: 2)); + final ack = AckedEntityIntent( + entityKey: EntitySyncKey.element(page.publicId, 'a'), + op: op, + ack: const AppliedOpAck(opId: 'restored-patch', revision: 2), + ); + queue.state = queue.state.copyWith( + lastAcks: [ack.ack], + lastAckBatch: [ack], + ); + await _settle(); + expect(container.read(textProvider).single.text, 'restored'); + expect(elementOps(container), isEmpty); + await _settle(); + }); + + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier)> openWithQueue( + _FakeRemoteEditorNotifier remote) async { + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, queue); + } + + void land(_FakeStrategyOpQueueNotifier queue, ElementPatchOp op, int rev) { + final ack = AckedEntityIntent( + entityKey: EntitySyncKey.element(page.publicId, op.elementPublicId), + op: op, + ack: AppliedOpAck(opId: op.opId, revision: rev), + ); + queue.state = queue.state.copyWith( + queuedByEntityKey: const {}, + lastAcks: [ack.ack], + lastAckBatch: [ack], + ); + } + + test('grabbing an item again as its own edit lands is no conflict', + () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openWithQueue(remote); + container + .read(textProvider.notifier) + .updatePosition(const Offset(200, 200), 'a'); + await _settle(); + final sent = queue + .state + .queuedByEntityKey[EntitySyncKey.element(page.publicId, 'a')]! + .pending + .op as ElementPatchOp; + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'a') + ..down(1); + land(queue, sent, 2); + container.read(strategySaveStateProvider.notifier).markPersisted(); + remote.setSnapshot(snapshot([ + RemoteElement( + publicId: 'a', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'text', + payload: sent.payload!, + sortIndex: 0, + revision: 2, + deleted: false, + ), + ], contentRevision: 2)); + await _settle(); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'a'); + expect(elementOps(container)['a']!.expectedRevision, 2); + await _settle(); + }); + + test('typing into an item before its own move lands is no conflict', + () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openWithQueue(remote); + container + .read(textProvider.notifier) + .updatePosition(const Offset(200, 200), 'a'); + await _settle(); + final sent = queue + .state + .queuedByEntityKey[EntitySyncKey.element(page.publicId, 'a')]! + .pending + .op as ElementPatchOp; + // The user starts typing into it before the move is acked. + container.read(textDraftProvider.notifier).setDraft('a', 'typing'); + land(queue, sent, 2); + container.read(strategySaveStateProvider.notifier).markPersisted(); + remote.setSnapshot(snapshot([ + RemoteElement( + publicId: 'a', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'text', + payload: sent.payload!, + sortIndex: 0, + revision: 2, + deleted: false, + ), + ], contentRevision: 2)); + await _settle(); + container.read(textDraftProvider.notifier).commitDraft('a'); + await _settle(); + // Against the user's own move (revision 2), not the version before it. + expect(elementOps(container)['a']!.expectedRevision, 2); + await _settle(); + }); + + test('typing into an item whose move the live read shows before its ack', + () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openWithQueue(remote); + container + .read(textProvider.notifier) + .updatePosition(const Offset(200, 200), 'a'); + await _settle(); + final sent = queue + .state + .queuedByEntityKey[EntitySyncKey.element(page.publicId, 'a')]! + .pending + .op as ElementPatchOp; + container.read(textDraftProvider.notifier).setDraft('a', 'typing'); + remote.setSnapshot(snapshot([ + RemoteElement( + publicId: 'a', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'text', + payload: sent.payload!, + sortIndex: 0, + revision: 2, + deleted: false, + ), + ], contentRevision: 2)); + // The waiting reload starts as the queue empties, before the session + // hears the ack. + land(queue, sent, 2); + await _settle(); + container.read(textDraftProvider.notifier).commitDraft('a'); + await _settle(); + expect(elementOps(container)['a']!.expectedRevision, 2); + await _settle(); + }); + + group('an edit recovered from the outbox', () { + final key = EntitySyncKey.element(page.publicId, 'a'); + final landed = _textElement(page.publicId, 'a', 'recovered', + revision: 2, worldSized: true); + final recovered = ElementPatchOp( + opId: 'recovered-edit', + pagePublicId: page.publicId, + elementPublicId: 'a', + expectedElementRevision: 1, + payload: landed.payload, + sortIndex: 0, + ); + + /// Opens the page while [recovered] waits in the outbox, as after a + /// restart: queued, or already sent if [inFlight]. + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier)> openRecovering( + _FakeRemoteEditorNotifier remote, { + bool inFlight = false, + }) async { + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + if (inFlight) { + queue.holdInFlight(key, recovered); + } else { + queue.state = queue.state.copyWith(queuedByEntityKey: { + key: QueuedEntityIntent( + entityKey: key, + pending: PendingOp(op: recovered, clientId: 'test-client'), + ), + }); + } + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, queue); + } + + void landRecovered(_FakeStrategyOpQueueNotifier queue) { + final ack = AckedEntityIntent( + entityKey: key, + op: recovered, + ack: const AppliedOpAck(opId: 'recovered-edit', revision: 2), + restored: true, + ); + queue.state = queue.state.copyWith( + queuedByEntityKey: const {}, + inFlightByEntityKey: const {}, + lastAcks: [ack.ack], + lastAckBatch: [ack], + ); + } + + for (final ackFirst in [false, true]) { + test( + 'landing under a held item makes the drop conflict ' + '(${ackFirst ? 'ack' : 'live read'} first)', () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openRecovering(remote); + expect(container.read(textProvider).single.text, 'a'); + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'a') + ..down(1); + if (ackFirst) landRecovered(queue); + remote.setSnapshot(snapshot([landed], contentRevision: 2)); + if (!ackFirst) landRecovered(queue); + await _settle(); + // Nothing re-sends the version on screen over the recovered edit. + expect(queue.state.queuedByEntityKey[key], isNull); + expect(container.read(textProvider).single.text, 'a'); + // The drag ends on the old text: sent against the version the user + // saw, so the server refuses it instead of replacing 'recovered'. + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'a'); + expect(elementOps(container)['a']!.expectedRevision, 1); + await _settle(); + }); + } + + test('an edit after it lands, before the page reloads, conflicts', + () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openRecovering(remote); + // The live read has not caught up with the landed edit yet. + landRecovered(queue); + await _settle(); + expect(queue.state.queuedByEntityKey[key], isNull); + expect(container.read(textProvider).single.text, 'a'); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'a'); + expect(elementOps(container)['a']!.expectedRevision, 1); + await _settle(); + }); + + test('landing with nothing held is taken from the next read', () async { + final remote = _FakeRemoteEditorNotifier(snapshot([text('a', 0)])); + final (container, queue) = await openRecovering(remote); + landRecovered(queue); + remote.setSnapshot(snapshot([landed], contentRevision: 2)); + await _settle(); + expect(container.read(textProvider).single.text, 'recovered'); + expect(elementOps(container), isEmpty); + await _settle(); + }); + + test('while it is in flight, other edits leave it as it is', () async { + final remote = + _FakeRemoteEditorNotifier(snapshot([text('a', 0), text('b', 1)])); + final (container, queue) = await openRecovering(remote, inFlight: true); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'b'); + expect(elementOps(container)['a']?.opId, 'recovered-edit'); + // The server applied it; its answer is still on the way. + remote + .setSnapshot(snapshot([landed, text('b', 1)], contentRevision: 2)); + await _settle(); + container + .read(textProvider.notifier) + .updatePosition(const Offset(320, 320), 'b'); + expect(elementOps(container)['a']?.opId, 'recovered-edit'); + await _settle(); + }); + + test( + 'an edit still on its way when the user leaves for a local ' + 'strategy and comes back conflicts with a drop over it', () async { + final box = await _openStrategyBox(); + await box.put( + 'local-strategy', + StrategyData( + id: 'local-strategy', + name: 'Local', + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: DateTime.utc(2026), + folderID: null, + pages: [ + StrategyPage( + id: 'local-page', + name: 'Page 1', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: const [], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ), + ], + ), + ); + final settings = StrategySettings().toJson(); + RemoteEditorSnapshot withSettings(List elements, + {int contentRevision = 1}) => + _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, + contentRevision: contentRevision, + settings: settings, + elements: elements), + ); + final remote = _FakeRemoteEditorNotifier(withSettings([text('a', 0)])); + final repository = _HeldFirstBatchRepository(); + final container = ProviderContainer(overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + durableStrategyOutboxStoreProvider + .overrideWithValue(MemoryDurableStrategyOutboxStore()), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_CloudReadyAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudMediaUploadQueueProvider + .overrideWith(() => _RecordingMediaQueue()), + ]); + addTearDown(container.dispose); + container.listen(strategyPageSessionProvider, (_, __) {}); + await container.read(remoteEditorSnapshotProvider.future); + final strategy = container.read(strategyProvider.notifier); + final session = container.read(strategyPageSessionProvider.notifier); + Future open(String id, StrategySource source) async { + // As openCloudStrategy and loadFromHive do. The queue stays on the + // cloud strategy throughout: leaving for a local one never + // switches it. + strategy.setFromState(StrategyState( + strategyId: id, + strategyName: id, + source: source, + storageDirectory: null, + isOpen: true, + )); + container.read(strategySaveStateProvider.notifier).reset(); + await session.initializeForStrategy( + strategyId: id, + source: source, + selectFirstPageIfNeeded: true, + ); + } + + container + .read(strategyOpQueueProvider.notifier) + .setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await open('cloud-strategy', StrategySource.cloud); + final drawnAt = container.read(textProvider).single.position; + container + .read(textProvider.notifier) + .updatePosition(const Offset(200, 200), 'a'); + await repository.firstSent.future.timeout(const Duration(seconds: 5)); + final move = repository.calls.first + .whereType() + .singleWhere((op) => op.elementPublicId == 'a'); + + await open('local-strategy', StrategySource.local); + await open('cloud-strategy', StrategySource.cloud); + // Drawn from the server, which has not applied the move yet. + expect(container.read(textProvider).single.position, drawnAt); + + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'a') + ..down(1); + remote.setSnapshot(withSettings([ + RemoteElement( + publicId: 'a', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'text', + payload: move.payload!, + sortIndex: 0, + revision: 2, + deleted: false, + ), + ], contentRevision: 2)); + // The first op for it sent from here on. + final nextEdit = repository.nextEditOf('a'); + repository.landFirst(); + await _settle(); + await _settle(); + final landing = container + .read(strategyOpQueueProvider) + .lastAckBatch + .singleWhere((acked) => acked.entityKey == key); + expect(container.read(textProvider).single.position, drawnAt); + + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'a'); + final sent = await nextEdit; + // Against the version the user saw: the server refuses it instead + // of replacing the move. + expect(sent.expectedRevision, 1); + // The queue said the move it accepted was not the canvas's work. + expect(landing.restored, isTrue); + await _settle(); + }); + }); + + test('moving past an element with the same sortIndex is sent', () async { + final container = await open( + _FakeRemoteEditorNotifier(snapshot([text('a', 3), text('b', 3)]))); + expect(elementOps(container), isEmpty); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'a'); + final ops = elementOps(container); + expect(ops.keys, ['a']); + expect((ops['a']! as ElementPatchOp).sortIndex, 4); + await _settle(); + }); + + test('a teammate restacking a held item re-sends nothing else', () async { + final remote = + _FakeRemoteEditorNotifier(snapshot([text('a', 0), text('b', 1)])); + final container = await open(remote); + container.read(textDraftProvider.notifier).setDraft('a', 'typing'); + // The teammate brings 'a' forward and adds 'c'. + remote.setSnapshot(snapshot( + [text('b', 1), text('a', 2, revision: 2), text('c', 3)], + contentRevision: 2)); + await _settle(); + expect(container.read(textProvider).map((t) => t.id), ['b', 'a', 'c']); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'b'); + final ops = elementOps(container); + // The user's move, and the open draft on 'a' (the user's words): sent + // at the revision they saw, so it conflicts with the teammate's edit, + // and at the server's place, not restacked. 'c' is not re-sent. + expect(ops.keys.toSet(), {'b', 'a'}); + expect(ops['a']!.expectedRevision, 1); + expect((ops['a']! as ElementPatchOp).sortIndex, 2); + await _settle(); + }); + + test('a held item the server deleted re-sends nothing else', () async { + final remote = _FakeRemoteEditorNotifier(snapshot( + [text('mover', 0), text('a', 1), text('held', 2), text('b', 3)])); + final container = await open(remote); + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'held') + ..down(1); + remote.setSnapshot(snapshot([ + text('mover', 0), + text('held', 2, revision: 2, deleted: true), + text('b', 3), + text('a', 4, revision: 2), + ], contentRevision: 2)); + await _settle(); + container + .read(textProvider.notifier) + .updatePosition(const Offset(400, 400), 'mover'); + expect(elementOps(container).keys, ['mover']); + await _settle(); + }); + }); + + group('holding an item while a teammate edits the page', () { + RemotePageSnapshot texts( + RemotePage page, + Map byId, { + required int contentRevision, + }) => + _pageSnapshot( + page, + contentRevision: contentRevision, + elements: [ + for (final (index, entry) in byId.entries.indexed) + _textElement(page.publicId, entry.key, entry.value.$1, + revision: entry.value.$2, sortIndex: index, worldSized: true), + ], + ); + + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> + open() async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: texts( + page, {'held': ('held v1', 1), 'other': ('other v1', 1)}, + contentRevision: 1), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + // The app-wide scope sees the press last, after the item's layer. + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'held') + ..down(1); + return (container, remote, page); + } + + String textOf(ProviderContainer container, String id) => + container.read(textProvider).firstWhere((text) => text.id == id).text; + + test('only the held item waits; it updates on release', () async { + final (container, remote, page) = await open(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: texts( + page, {'held': ('held v2', 2), 'other': ('other v2', 2)}, + contentRevision: 2), + )); + await _settle(); + expect(textOf(container, 'other'), 'other v2'); + expect(textOf(container, 'held'), 'held v1'); + + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + expect(textOf(container, 'held'), 'held v2'); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + + test('an edit committed to it is checked against the version the user saw', + () async { + final (container, remote, page) = await open(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: texts( + page, {'held': ('held v2', 2), 'other': ('other v1', 1)}, + contentRevision: 2), + )); + await _settle(); + // The drag ends: the move commits before the hold releases. + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'held'); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + + final sent = container + .read(strategyOpQueueProvider) + .pending + .singleWhere((item) => item.op.entityPublicId == 'held'); + // Revision 1, not the teammate's 2: the server rejects it as a + // conflict instead of the move silently overwriting their edit. + expect(sent.op.expectedRevision, 1); + }); + + test('a teammate deleting it leaves it under the pointer until release', + () async { + final (container, remote, page) = await open(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: texts(page, {'other': ('other v1', 1)}, contentRevision: 2), + )); + await _settle(); + expect(container.read(textProvider).map((text) => text.id), + containsAll(['held', 'other'])); + + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + expect(container.read(textProvider).map((text) => text.id), ['other']); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + + test('a teammate deleting an item re-sends no other item', () async { + final (container, remote, page) = await open(); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + // The server keeps 'other' at sortIndex 1; it never renumbers. + activePage: _pageSnapshot(page, contentRevision: 2, elements: [ + _textElement(page.publicId, 'other', 'other v1', + sortIndex: 1, worldSized: true), + ]), + )); + await _settle(); + final ops = container + .read(activePageLiveSyncProvider.notifier) + .syncLocalPage( + strategyPublicId: 'cloud-strategy', pageId: page.publicId)!; + expect( + ops.keys + .where((key) => key.kind == EntitySyncKeyKind.element) + .map((key) => key.entityId), + isEmpty); + await _settle(); + }); + + test('moving an item brings it forward and sends only its sortIndex', + () async { + final (container, remote, page) = await open(); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + // A move re-appends the item: it now stacks above 'other'. + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'held'); + final ops = container + .read(activePageLiveSyncProvider.notifier) + .syncLocalPage( + strategyPublicId: 'cloud-strategy', pageId: page.publicId)!; + final elementOps = { + for (final entry in ops.entries) + if (entry.key.kind == EntitySyncKeyKind.element) + entry.key.entityId: entry.value, + }; + expect(elementOps.keys, ['held']); + expect((elementOps['held']! as ElementPatchOp).sortIndex, 2); + await _settle(); + + // The move lands and comes back: it stays on top. + final queue = container.read(strategyOpQueueProvider.notifier) + as _FakeStrategyOpQueueNotifier; + queue.state = queue.state.copyWith( + queuedByEntityKey: const {}); + container.read(strategySaveStateProvider.notifier).markPersisted(); + final moved = + container.read(textProvider).firstWhere((text) => text.id == 'held'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, contentRevision: 2, elements: [ + _textElement(page.publicId, 'other', 'other v1', + sortIndex: 1, worldSized: true), + RemoteElement( + publicId: 'held', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'text', + payload: cloudElementPayload( + kind: 'text', data: {...moved.toJson(), 'elementType': 'text'}), + sortIndex: 2, + revision: 2, + deleted: false, + ), + ]), + )); + await _settle(); + expect(container.read(textProvider).map((text) => text.id), + ['other', 'held']); + }); + + test('an open text draft holds its text the same way', () async { + final (container, remote, page) = await open(); + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + container.read(textDraftProvider.notifier).setDraft('held', 'typing'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: texts( + page, {'held': ('held v2', 2), 'other': ('other v2', 2)}, + contentRevision: 2), + )); + await _settle(); + expect(textOf(container, 'other'), 'other v2'); + expect(textOf(container, 'held'), 'held v1'); + expect(container.read(textDraftProvider)['held'], 'typing'); + + container.read(textDraftProvider.notifier).clearDraft('held'); + await _settle(); + expect(textOf(container, 'held'), 'held v2'); + }); + }); + + group('holding a lineup spot while a teammate edits the page', () { + const moved = Offset(500, 500); + + /// Lineup `link-a` from origin `a` and lineup `link-a2` from the same + /// origin, one group, and lineup `link-b` on spots of its own, its own + /// group; each origin at [at]. With [teammateLineup], a teammate's + /// lineup `link-c` from origin `a` has joined `a`'s group. + List lineups( + RemotePage page, { + Offset at = const Offset(10, 20), + int revision = 1, + String notes = 'remote lineup', + bool teammateLineup = false, + }) => + [ + _groupRow( + page.publicId, + 'link-a', + origins: [_originJson('a', at)], + landings: [ + _landingJson('landing-a'), + _landingJson('landing-a2', const Offset(60, 60)), + if (teammateLineup) + _landingJson('landing-c', const Offset(90, 90)), + ], + links: [ + _linkJson('link-a', + originId: 'a', landingId: 'landing-a', notes: notes), + _linkJson('link-a2', + originId: 'a', landingId: 'landing-a2', notes: notes), + if (teammateLineup) + _linkJson('link-c', originId: 'a', landingId: 'landing-c'), + ], + revision: revision, + ), + _groupRow( + page.publicId, + 'link-b', + origins: [_originJson('b', at)], + landings: [_landingJson('landing-b')], + links: [ + _linkJson('link-b', + originId: 'b', landingId: 'landing-b', notes: notes), + ], + revision: revision, + sortIndex: 1, + ), + ]; + + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> + open() async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before', lineups: lineups(page)), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, remote, page); + } + + Offset originAt(ProviderContainer container, String id) => + container.read(lineUpProvider).originById(id)!.agent.position; + + test('dragging a spot holds only its lineup group until release', () async { + final (container, remote, page) = await open(); + container.read(editorPointersProvider.notifier) + ..holdEntity(1, 'landing-a') + ..down(1); + + // A teammate moves every origin, edits every lineup's notes, and adds + // a lineup to the held lineup's origin, so to its group. + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, + text: 'after', + contentRevision: 2, + lineups: lineups(page, + at: moved, + revision: 2, + notes: 'teammate notes', + teammateLineup: true)), + )); + await _settle(); + + // The dragged landing's lineup waits, and so does every lineup in its + // group: the group is one row, so it takes one side whole. + final lineUps = container.read(lineUpProvider); + expect(container.read(textProvider).single.text, 'after'); + expect(originAt(container, 'a'), const Offset(10, 20)); + expect(lineUps.linkById('link-a')!.notes, 'remote lineup'); + expect(lineUps.linkById('link-a2')!.notes, 'remote lineup'); + expect(lineUps.linkById('link-c'), isNull); + // The unrelated lineup on the same page takes the change at once. + expect(originAt(container, 'b'), moved); + expect(lineUps.linkById('link-b')!.notes, 'teammate notes'); + + container.read(editorPointersProvider.notifier).release(1); + await _settle(); + + final released = container.read(lineUpProvider); + expect(originAt(container, 'a'), moved); + expect(released.linkById('link-a')!.notes, 'teammate notes'); + expect(released.linkById('link-a2')!.notes, 'teammate notes'); + expect(released.links.map((link) => link.id), + ['link-a', 'link-a2', 'link-c', 'link-b']); + expect(released.origins, hasLength(2)); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + + test('a placement pinned to an origin holds only that origin\'s group', + () async { + final (container, remote, page) = await open(); + container.read(lineUpProvider.notifier).startFromOrigin('a'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, + text: 'after', + contentRevision: 2, + lineups: lineups(page, at: moved, revision: 2)), + )); + await _settle(); + expect(container.read(textProvider).single.text, 'after'); + expect(originAt(container, 'a'), const Offset(10, 20)); + expect(originAt(container, 'b'), moved); + expect(container.read(lineUpProvider).placement?.pinnedOriginId, 'a'); + + container.read(lineUpProvider.notifier).clearPlacement(); + await _settle(); + expect(originAt(container, 'a'), moved); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + }); + + for (final startDuringLoad in [false, true]) { + test( + 'remote hydration waits for pointer, started during load=$startDuringLoad', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + final pointers = container.read(editorPointersProvider.notifier); + if (!startDuringLoad) pointers.down(1); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'after', contentRevision: 2), + )); + if (startDuringLoad) pointers.down(1); + await _settle(); + expect(container.read(textProvider).single.text, 'before'); + expect(container.read(activePageLiveSyncProvider).hydratedPageId, + page.publicId); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'latest', contentRevision: 3), + )); + await _settle(); + expect(container.read(textProvider).single.text, 'before'); + pointers.release(1); + await _settle(); + expect(container.read(textProvider).single.text, 'latest'); + expect(container.read(strategyOpQueueProvider).pending, isEmpty); + }); + } + + test('a page switch supersedes a remote reload already in progress', + () async { + final one = _page('page-1', 0); + final two = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [one, two], + activePage: _pageSnapshot(one, text: 'one'), + ), + pageCatalog: { + one.publicId: _pageSnapshot(one, text: 'one'), + two.publicId: _pageSnapshot(two, text: 'two'), + }); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true); + remote.setSnapshot(_editorSnapshot( + pages: [one, two], + activePage: _pageSnapshot(one, text: 'stale', contentRevision: 2))); + await session.setActivePage(two.publicId); + await _settle(); + expect( + container.read(strategyPageSessionProvider).activePageId, two.publicId); + expect(container.read(textProvider).single.text, 'two'); + }); + + test('remote hydration preserves and queues a committed text draft', + () async { + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + ); + final remote = _FakeRemoteEditorNotifier(before); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + const textId = 'text-page-1'; + container.read(textDraftProvider.notifier).setDraft(textId, 'local-intent'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: + _pageSnapshot(page, text: 'remote-change', contentRevision: 2), + )); + await _settle(); + + container.read(textDraftProvider.notifier).commitDraft(textId); + await _settle(); + + expect(container.read(textProvider).single.text, 'local-intent'); + expect(container.read(textDraftProvider), isEmpty); + final pending = container.read(strategyOpQueueProvider).pending; + expect(pending, isNotEmpty); + expect( + pending.any((item) => + item.op.entityPublicId == textId && + item.op.payload.toString().contains('local-intent')), + isTrue, + ); + }); + + test('rejected local intent stays visible and requires attention', () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'server-before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).commitText( + 'text-page-1', + 'local-losing-intent', + ); + await _settle(); + final op = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == 'text-page-1'); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + text: 'server-winner', + contentRevision: 2, + ), + )); + queue.reject(op); + await _settle(); + + expect(container.read(textProvider).single.text, 'local-losing-intent'); + expect(container.read(strategyOpQueueProvider).needsAttention, isTrue); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + expect(container.read(strategyConflictProvider), hasLength(1)); + expect(container.read(strategyConflictProvider).single.opId, op.opId); + }); + + test( + 'using cloud after a conflict replaces the canvas without resubmitting it', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, 'text-page-1', 'server-before', + worldSized: true), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + const textId = 'text-page-1'; + const key = EntitySyncKey.element('page-1', textId); + container.read(textProvider.notifier).commitText( + textId, + 'local-losing-intent', + ); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == textId); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, textId, 'server-winner', + worldSized: true), + ], + contentRevision: 2, + ), + )); + queue.reject(rejectedOp); + await _settle(); + container + .read(textDraftProvider.notifier) + .setDraft(textId, 'draft-in-progress'); + container + .read(textDraftProvider.notifier) + .setDraft('unrelated-text', 'keep-this-draft'); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(textProvider).single.text, 'server-winner'); + expect(container.read(textDraftProvider), { + 'unrelated-text': 'keep-this-draft', + }); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(key)), + ); + expect( + container.read(activePageLiveSyncProvider).overlayByEntityKey, + isNot(contains(key)), + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + + container.read(textDraftProvider.notifier).clearDraft(textId); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect(desired, isNot(contains(key))); + await queue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired!, + flushImmediately: false, + ); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityPublicId), + isNot(contains(textId)), + ); + }); + + for (final failDiscard in [false, true]) { + test('use cloud preserves unrelated edits when discard fails=$failDiscard', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement(page.publicId, 'conflicted', 'server-before', + worldSized: true), + _textElement(page.publicId, 'unrelated', 'server-original', + worldSized: true), + ]), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + queue.failDiscard = failDiscard; + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container + .read(textProvider.notifier) + .commitText('conflicted', 'local-loser'); + await _settle(); + final rejected = container + .read(strategyOpQueueProvider) + .pending + .map((p) => p.op) + .firstWhere((op) => op.entityPublicId == 'conflicted'); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, contentRevision: 2, elements: [ + _textElement(page.publicId, 'conflicted', 'server-winner', + worldSized: true), + _textElement(page.publicId, 'unrelated', 'server-original', + worldSized: true), + ]), + )); + queue.reject(rejected); + await _settle(); + container + .read(textProvider.notifier) + .commitText('unrelated', 'keep-my-edit'); + container.read(mapProvider.notifier).fromHive(MapValue.haven, true); + container + .read(strategyThemeProvider.notifier) + .fromStrategy(profileId: 'local-theme'); + await _settle(); + expect(container.read(strategyOpQueueProvider).queuedByEntityKey, + contains(const EntitySyncKey.element('page-1', 'unrelated'))); + expect(await session.useCloudVersionsForRejected(), !failDiscard); + await _settle(); + final canvas = { + for (final text in container.read(textProvider)) text.id: text.text + }; + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )!; + await queue.syncDesiredOpsForPage( + pageId: 'page-1', desiredOpsByEntityKey: desired); + expect(canvas['unrelated'], 'keep-my-edit'); + expect( + canvas['conflicted'], failDiscard ? 'local-loser' : 'server-winner'); + expect(container.read(mapProvider).currentMap, MapValue.haven); + expect(container.read(strategyThemeProvider).profileId, 'local-theme'); + final queued = container.read(strategyOpQueueProvider).queuedByEntityKey; + expect( + queued[const EntitySyncKey.element('page-1', 'unrelated')]! + .pending + .op + .payload + .toString(), + contains('keep-my-edit')); + expect(queued, + isNot(contains(const EntitySyncKey.element('page-1', 'conflicted')))); + }); + } + + test('using cloud with no remaining attention is a silent no-op', () async { + final page = _page('page-1', 0); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote'), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + expect(await session.useCloudVersionsForRejected(), isTrue); + }); + + test('failed cloud hydration keeps the rejected work available', () async { + final page = _page('page-1', 0); + const textId = 'text-page-1'; + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(textProvider.notifier).commitText(textId, 'local-edit'); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityPublicId == textId); + final malformedLineup = RemoteLineup( + publicId: 'bad-lineup', + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + payload: const { + 'kind': 'lineup', + 'payloadVersion': 1, + 'data': {'id': 'bad-lineup', 'broken': true}, + }, + sortIndex: 0, + revision: 1, + deleted: false, + ); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement(page.publicId, textId, 'server-winner'), + ], + lineups: [malformedLineup], + ), + )); + queue.reject(rejectedOp); + await _settle(); + + await expectLater( + session.useCloudVersionsForRejected(), + throwsA(isA()), + ); + + final key = EntitySyncKey.element(page.publicId, textId); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + contains(key), + ); + expect( + container.read(activePageLiveSyncProvider).overlayByEntityKey, + contains(key), + ); + }); + + test('using cloud for a strategy conflict restores remote map and theme', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + shellRevision: 3, + mapData: Maps.mapNames[MapValue.haven], + themeProfileId: 'remote-theme', + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(mapProvider.notifier).updateMap(MapValue.ascent); + container.read(strategyThemeProvider.notifier).setProfile('local-theme'); + await _settle(); + final rejectedOp = container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op) + .firstWhere((op) => op.entityType == StrategyOpEntityType.strategy); + queue.reject(rejectedOp); + await _settle(); + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + + expect(resolved, isTrue); + expect(container.read(mapProvider).currentMap, MapValue.haven); + expect(container.read(strategyThemeProvider).profileId, 'remote-theme'); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + isNot(contains(const EntitySyncKey.strategy())), + ); + + await container + .read(strategyProvider.notifier) + .notifyCloudStrategyMutation(flushImmediately: false); + expect( + container + .read(strategyOpQueueProvider) + .pending + .map((pending) => pending.op.entityType), + isNot(contains(StrategyOpEntityType.strategy)), + ); + }); + + test('inactive page shell update does not rehydrate the active canvas', + () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final before = _editorSnapshot( + pages: [pageOne, pageTwo], + activePage: _pageSnapshot(pageOne, text: 'remote'), + ); + final remote = _FakeRemoteEditorNotifier(before); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local', position: const Offset(5, 5))..text = 'local', + ]); + + remote.setSnapshot(_editorSnapshot( + pages: [pageOne, _page('page-2', 1, revision: 2, name: 'Renamed')], + activePage: _pageSnapshot(pageOne, text: 'remote'), + shellRevision: 2, + )); + await _settle(); + expect(container.read(textProvider).single.text, 'local'); + }); + + test('outbound diff waits for the matching active-page remote base', + () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [pageOne, pageTwo], + activePage: _pageSnapshot(pageTwo, text: 'remote-two'), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local-one', position: const Offset(5, 5)) + ..text = 'local-one', + ]); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + ); + + expect(desired, isNull); + }); + + test('final side is emitted while the opposite side is in flight', () async { + final page = _page('page-1', 0, revision: 7); + final queue = _FakeStrategyOpQueueNotifier(); + const key = EntitySyncKey.pageDescriptor('page-1'); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, + ); + container.read(strategyOpQueueProvider); + final sync = container.read(activePageLiveSyncProvider.notifier) + ..markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, + ); + // The user switches to defense, and that is sent. + container.read(mapProvider.notifier).setAttack(false); + final defense = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + )![key]!; + expect(defense.payload, {'isAttack': false}); + queue.holdInFlight(key, defense); + // They switch back while it is on its way. + container.read(mapProvider.notifier).setAttack(true); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final sideOp = desired![key] as PagePatchOp; + expect(sideOp.payload, {'isAttack': true}); + expect(sideOp.expectedPageRevision, 7); + expect( + desired.keys.where((candidate) => + candidate.kind == EntitySyncKeyKind.element || + candidate.kind == EntitySyncKeyKind.lineup), + isEmpty, + ); + }); + + test('final text is emitted while an earlier edit is in flight', () async { + final page = _page('page-1', 0); + const textId = 'text-page-1'; + final remoteText = _textElement( + page.publicId, + textId, + 'before', + worldSized: true, + ); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [remoteText]), + )), + queue: queue, + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + const key = EntitySyncKey.element('page-1', textId); + final firstEdit = ElementPatchOp( + opId: 'first-edit-in-flight', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement( + page.publicId, + textId, + 'first-edit', + worldSized: true, + ).payload, + sortIndex: 0, + expectedElementRevision: 1, + ); + queue.holdInFlight(key, firstEdit); + container.read(textDraftProvider.notifier).setDraft(textId, 'final-edit'); + + final beforeAck = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + )![key] as ElementPatchOp; + expect(beforeAck.payload.toString(), contains('final-edit')); + expect(beforeAck.expectedElementRevision, 1); + + container.read(activePageLiveSyncProvider.notifier).recordAckBatch([ + AckedEntityIntent( + entityKey: key, + op: firstEdit, + ack: const AppliedOpAck( + opId: 'first-edit-in-flight', + revision: 2, + ), + ), + ]); + queue.clearInFlight(); + + final afterAck = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + )![key] as ElementPatchOp; + expect(afterAck.payload.toString(), contains('final-edit')); + expect(afterAck.expectedElementRevision, 2); + }); + + test('an ack after leaving a page advances its retained overlay revision', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement('page-1', 'text-a', 'before', worldSized: true), + ]), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-a', position: const Offset(10, 20)) + ..text = 'my edit' + ..markSizeAsWorld(), + ]); + const key = EntitySyncKey.element('page-1', 'text-a'); + final op = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )![key]!; + sync.setContext(strategyPublicId: 'cloud-strategy', activePageId: 'page-2'); + sync.recordAckBatch([ + AckedEntityIntent( + entityKey: key, op: op, ack: AppliedOpAck(opId: op.opId, revision: 2)) + ]); + expect( + container + .read(activePageLiveSyncProvider) + .overlayByEntityKey[key]! + .baseRevision, + 2); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, elements: [ + _textElement('page-1', 'text-a', 'my edit', + revision: 2, worldSized: true), + ]))); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + container.read(textProvider.notifier).commitText('text-a', 'next edit'); + final next = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )![key] as ElementPatchOp; + expect(next.expectedElementRevision, 2); + expect(next.payload.toString(), contains('next edit')); + }); + + test('rename acks preserve the side baseline during a remote side change', + () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )); + final container = await _syncContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + container.read(mapProvider.notifier).fromHive(MapValue.ascent, true); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + snapshot: remote.initialSnapshot); + const key = EntitySyncKey.pageDescriptor('page-1'); + sync.recordAckBatch([ + const AckedEntityIntent( + entityKey: key, + op: PagePatchOp( + opId: 'rename', + pagePublicId: 'page-1', + payload: {'name': 'Renamed'}, + expectedPageRevision: 1), + ack: AppliedOpAck(opId: 'rename', revision: 2), + ) + ]); + final changed = + _page('page-1', 0, revision: 3, name: 'Renamed', isAttack: false); + remote.setSnapshot(_editorSnapshot( + pages: [changed], + activePage: _pageSnapshot(changed), + )); + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )!; + expect(desired, isNot(contains(key))); + expect( + sync + .projectPageState( + strategyPublicId: 'cloud-strategy', + pageId: 'page-1', + )! + .isAttack, + isFalse); + }); + + test('a final delete is emitted behind an in-flight local add', () async { + final page = _page('page-1', 0); + const textId = 'new-local-text'; + const key = EntitySyncKey.element('page-1', textId); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )), + queue: queue, + ); + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: textId, position: const Offset(10, 20)) + ..text = 'first' + ..markSizeAsWorld(), + ]); + + final firstDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + final add = firstDesired![key] as ElementAddOp; + queue.holdInFlight(key, add); + container.read(textProvider.notifier).removeText(textId); + + final finalDesired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final delete = finalDesired![key] as ElementDeleteOp; + expect(delete.expectedElementRevision, 0); + }); + + test('restart retains a queued add missing from canvas and remote', () async { + final page = _page('page-1', 0); + const textId = 'queued-before-restart'; + const key = EntitySyncKey.element('page-1', textId); + final add = ElementAddOp( + opId: 'add-before-restart', + elementPublicId: textId, + pagePublicId: page.publicId, + payload: _textElement(page.publicId, textId, 'unsent').payload, + sortIndex: 0, + ); + final store = MemoryDurableStrategyOutboxStore(); + final firstContainer = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + ]); + firstContainer + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final firstQueue = firstContainer.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await firstQueue.enqueue(add, flushImmediately: false); + expect(store.load().records.single.pending.op.opId, 'add-before-restart'); + firstContainer.dispose(); + + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page), + )); + final restarted = ProviderContainer(overrides: [ + durableStrategyOutboxStoreProvider.overrideWithValue(store), + strategyOutboxSessionProvider.overrideWithValue( + const StrategyOutboxSession( + accountId: null, + isReady: false, + hasAuthIncident: false, + ), + ), + remoteEditorSnapshotProvider.overrideWith(() => remote), + ]); + addTearDown(restarted.dispose); + restarted + .read(cloudCollabModeProvider.notifier) + .setForceLocalFallback(true); + final restartedQueue = restarted.read(strategyOpQueueProvider.notifier) + ..setActiveStrategy('cloud-strategy', accountId: 'account-a'); + await restarted.read(remoteEditorSnapshotProvider.future); + final sync = restarted.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: restarted.read(remoteEditorSnapshotProvider).requireValue!, + ); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + final retained = desired![key] as ElementAddOp; + expect(retained.opId, 'add-before-restart'); + expect(retained.payload, add.payload); + await restartedQueue.syncDesiredOpsForPage( + pageId: page.publicId, + desiredOpsByEntityKey: desired, + flushImmediately: false, + ); + expect( + restarted + .read(strategyOpQueueProvider) + .queuedByEntityKey[key]! + .pending + .op, + isA(), + ); + final durable = store.load().records.singleWhere( + (record) => record.entityKey == key, + ); + expect(durable.pending.op.opId, 'add-before-restart'); + }); + + test('hydration keeps the exact snapshot used to load the canvas', () async { + const textId = 'text-page-1'; + final hydratedPage = _page('page-1', 0); + final hydratedSnapshot = _editorSnapshot( + pages: [hydratedPage], + activePage: _pageSnapshot( + hydratedPage, + elements: [ + _textElement( + hydratedPage.publicId, + textId, + 'hydrated-value', + worldSized: true, + ), + ], + ), + ); + final newerPage = _page('page-1', 0, revision: 2); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [newerPage], + activePage: _pageSnapshot( + newerPage, + elements: [ + _textElement( + newerPage.publicId, + textId, + 'newer-remote-value', + revision: 2, + worldSized: true, + ), + ], + ), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + final hydratedText = PlacedText( + id: textId, + position: const Offset(10, 20), + ) + ..text = 'hydrated-value' + ..markSizeAsWorld(); + container.read(textProvider.notifier).fromHive([hydratedText]); + + final sync = container.read(activePageLiveSyncProvider.notifier); + sync.markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: hydratedPage.publicId, + snapshot: hydratedSnapshot, + ); + container.read(textDraftProvider.notifier).setDraft(textId, 'local-edit'); + + final desired = sync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: hydratedPage.publicId, + ); + + final op = desired![EntitySyncKey.element(hydratedPage.publicId, textId)] + as ElementPatchOp; + expect(op.payload.toString(), contains('local-edit')); + expect(op.expectedElementRevision, 1); + }); + + test('side switch authors exactly one Page descriptor operation', () async { + final page = _page('page-1', 0, revision: 11); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + settings: const { + 'agentSize': 35.0, + 'abilitySize': 25.0, + 'useNeutralTeamColors': false, + }, + ), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + container.read(activePageLiveSyncProvider.notifier).markPageHydrated( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + snapshot: container.read(remoteEditorSnapshotProvider).requireValue!, + ); + + container.read(mapProvider.notifier).switchSide(); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + expect(desired, hasLength(1)); + final entry = desired!.entries.single; + expect(entry.key, const EntitySyncKey.pageDescriptor('page-1')); + final op = entry.value as PagePatchOp; + expect(op.payload, {'isAttack': false}); + expect(op.expectedPageRevision, 11); + }); + + Future openCloudPage( + RemotePage page, + List lineups, + ) async { + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote', lineups: lineups), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return container; + } + + Map lineupOpsAfterTextEdit( + ProviderContainer container, + RemotePage page, + ) { + container.read(textProvider).single.position = const Offset(50, 60); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect( + desired![EntitySyncKey.element(page.publicId, 'text-page-1')]?.kind, + StrategyOpKind.patch, + ); + return { + for (final entry in desired.entries) + if (entry.key.kind == EntitySyncKeyKind.lineup) entry.key: entry.value, + }; + } + + test('remote lineup survives hydration and an unrelated outbound diff', + () async { + final page = _page('page-1', 0); + final container = + await openCloudPage(page, [_lineup(page.publicId, 'lineup-1')]); + + final lineUps = container.read(lineUpProvider); + expect(lineUps.origins.single.id, 'lineup-1'); + expect(lineUps.landings.single.id, 'landing-lineup-1'); + expect(lineUps.links.single.id, 'link-lineup-1'); + + expect(lineupOpsAfterTextEdit(container, page), isEmpty); + await _settle(); + }); + + test( + 'a group row draws its shared spots once, opening the page writes ' + 'nothing, and an edit writes the whole group', () async { + final page = _page('page-1', 0); + // link-1 and link-2 share an origin, link-2 and link-3 a landing: one + // group, one row. + final container = await openCloudPage(page, [ + _groupRow( + page.publicId, + 'link-1', + origins: [ + _originJson('origin', const Offset(100, 100)), + _originJson('origin-3'), + ], + landings: [ + _landingJson('landing-1'), + _landingJson('landing', const Offset(220, 220)), + ], + links: [ + _linkJson('link-1', originId: 'origin', landingId: 'landing-1'), + _linkJson('link-2', originId: 'origin', landingId: 'landing'), + _linkJson('link-3', originId: 'origin-3', landingId: 'landing'), + ], + revision: 9, + ), + ]); + + final lineUps = container.read(lineUpProvider); + expect(lineUps.origins.map((origin) => origin.id), ['origin', 'origin-3']); + expect(lineUps.landings.map((landing) => landing.id), + ['landing-1', 'landing']); + expect([ + for (final link in lineUps.links) (link.id, link.originId, link.landingId) + ], [ + ('link-1', 'origin', 'landing-1'), + ('link-2', 'origin', 'landing'), + ('link-3', 'origin-3', 'landing'), + ]); + expect( + lineUps.originById('origin')!.agent.position, const Offset(100, 100)); + expect(lineUps.landingById('landing')!.ability.position, + const Offset(220, 220)); + + // Opening the page writes no lineup, nor does an unrelated edit. + await _settle(); + Map queuedLineupOps() => { + for (final entry in container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries) + if (entry.key.kind == EntitySyncKeyKind.lineup) + entry.key: entry.value.pending.op, + }; + expect(queuedLineupOps(), isEmpty); + expect(lineupOpsAfterTextEdit(container, page), isEmpty); + + // An edit to link-2 writes the group's row, every lineup and spot in it + // under the same ids, against the group's revision. + container + .read(lineUpProvider.notifier) + .updateLink(lineUps.linkById('link-2')!.copyWith(notes: 'jump throw')); + await _settle(); + final group = EntitySyncKey.lineup(page.publicId, 'link-1'); + expect(queuedLineupOps().keys, [group]); + final patch = queuedLineupOps()[group]! as LineupPatchOp; + expect(patch.expectedLineupRevision, 9); + final data = cloudPayloadData(patch.payload); + expect(data['id'], 'link-1'); + expect(_entries(data, 'links').map((link) => link['id']), + ['link-1', 'link-2', 'link-3']); + expect(_entry(data, 'links', 'link-2')['notes'], 'jump throw'); + expect(_entry(data, 'links', 'link-1')['notes'], 'remote lineup'); + final origin = _entry(data, 'origins', 'origin'); + expect((origin['agent'] as Map)['lineUpID'], 'origin'); + expect((origin['agent'] as Map)['position'], {'dx': 100.0, 'dy': 100.0}); + final landing = _entry(data, 'landings', 'landing'); + expect((landing['ability'] as Map)['lineUpID'], 'landing'); + expect((landing['ability'] as Map)['position'], {'dx': 220.0, 'dy': 220.0}); + await _settle(); + }); + + test( + 'a page duplicated before cloud sync writes its lineups to its own ' + 'group rows, never the other page\'s', () async { + final one = _page('page-1', 0); + final two = _page('page-2', 1); + // Page 2 repeats page 1's lineup ids. A group's id is unique in the + // strategy, so its group took another id. It also has a group a + // teammate deleted, named after a lineup id. + final oneSnapshot = + _pageSnapshot(one, lineups: [_lineup(one.publicId, 'a')]); + final twoSnapshot = _pageSnapshot(two, lineups: [ + _groupRow( + two.publicId, + 'dup-group', + origins: [_originJson('a', const Offset(50, 50))], + landings: [_landingJson('landing-a')], + links: [_linkJson('link-a', originId: 'a', landingId: 'landing-a')], + ), + _groupRow( + two.publicId, + 'link-b', + origins: [_originJson('b')], + landings: [_landingJson('landing-b')], + links: [_linkJson('link-b', originId: 'b', landingId: 'landing-b')], + revision: 2, + sortIndex: 1, + deleted: true, + ), + ]); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot(pages: [one, two], activePage: oneSnapshot), + pageCatalog: {one.publicId: oneSnapshot, two.publicId: twoSnapshot}, + ); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + final liveSync = container.read(activePageLiveSyncProvider.notifier); + Map lineupOps() => { + for (final MapEntry(:key, :value) + in queue.state.queuedByEntityKey.entries) + if (key.kind == EntitySyncKeyKind.lineup) key: value.pending.op, + }; + void editNotes(String notes) { + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: notes)); + } + + final oneKey = EntitySyncKey.lineup(one.publicId, 'link-a'); + final twoKey = EntitySyncKey.lineup(two.publicId, 'dup-group'); + + // An edit on page 1 writes page 1's group. + editNotes('on one'); + await _settle(); + expect(lineupOps().keys, [oneKey]); + + // On page 2, the same lineup id is page 2's lineup, in page 2's group. + await session.setActivePage(two.publicId); + await _settle(); + expect(container.read(lineUpProvider).originById('a')!.agent.position, + const Offset(50, 50)); + editNotes('on two'); + await _settle(); + expect(lineupOps().keys, unorderedEquals([oneKey, twoKey])); + final patch = lineupOps()[twoKey]! as LineupPatchOp; + expect(cloudPayloadData(patch.payload)['id'], 'dup-group'); + expect(_onlyLinkIn(patch.payload)['notes'], 'on two'); + expect(_onlyLinkIn(lineupOps()[oneKey]!.payload as CloudPayload)['notes'], + 'on one'); + expect(liveSync.lineupGroupOf(one.publicId, 'link-a'), 'link-a'); + expect(liveSync.lineupGroupOf(two.publicId, 'link-a'), 'dup-group'); + + // Back on page 1, its lineup still writes page 1's group. + await session.setActivePage(one.publicId); + await _settle(); + expect(container.read(lineUpProvider).originById('a')!.agent.position, + const Offset(10, 20)); + editNotes('on one again'); + await _settle(); + expect(_onlyLinkIn(lineupOps()[oneKey]!.payload as CloudPayload)['notes'], + 'on one again'); + expect(lineupOps().keys, unorderedEquals([oneKey, twoKey])); + + // On page 2, lineups added under an id a group this page deleted is + // named after (an old copy brought back, say) start a new group under + // another id, never landing on the teammate's deleted group. + await session.setActivePage(two.publicId); + await _settle(); + container.read(lineUpProvider.notifier).addRecovered( + lineUpGraphFromRemoteLineups([_lineup(two.publicId, 'b')])); + final desired = liveSync.syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: two.publicId, + )!; + final added = { + for (final MapEntry(:key, :value) in desired.entries) + if (key.kind == EntitySyncKeyKind.lineup && value is LineupAddOp) + key: value, + }; + final newKey = added.keys.single; + expect(newKey.pageId, two.publicId); + expect(newKey.entityId, isNot(anyOf('link-b', 'link-a', 'dup-group'))); + expect(added[newKey]!.expectedLineupRevision, isNull); + expect(_onlyLinkIn(added[newKey]!.payload)['id'], 'link-b'); + await _settle(); + }); + + test('a Paranoia saved at the old size opens corrected and sends nothing', + () async { + final page = _page('page-1', 0); + final paranoia = PlacedAbility( + id: 'paranoia', + data: AgentData.agents[AgentType.omen]!.abilities[1], + position: const Offset(400, 300), + ); + // As a 4.6.3-era client wrote it: payload version 1, the old 25 m size. + final saved = { + ...cloudElementPayload( + kind: 'ability', + data: {...paranoia.toJson(), 'elementType': 'ability'}, + ), + 'payloadVersion': 1, + }; + // What the editor snapshot holds once the read path has upgraded it. + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: upgradeRemotePageSnapshot( + _pageSnapshot(page, elements: [ + _textElement(page.publicId, 'text-page-1', 'remote'), + RemoteElement( + publicId: paranoia.id, + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: 'ability', + payload: saved, + sortIndex: 1, + revision: 1, + deleted: false, + ), + ]), + Maps.mapNames[MapValue.ascent]!, + ), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + expect( + container.read(abilityProvider).single.position, + ParanoiaRangeMigration.migrateAbility(paranoia, MapValue.ascent).position, + ); + + container.read(textProvider).single.position = const Offset(50, 60); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect( + desired!.keys, + allOf( + contains(EntitySyncKey.element(page.publicId, 'text-page-1')), + isNot(contains(EntitySyncKey.element(page.publicId, 'paranoia'))), + ), + ); + await _settle(); + }); + + group('switching side on all cloud pages', () { + Future<(ProviderContainer, _FakeStrategyOpQueueNotifier)> + openTwoPages() async { + final first = _page('page-a', 0, revision: 4); + final second = _page('page-b', 1, revision: 7); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, queue); + } + + bool? queuedSideOf(_FakeStrategyOpQueueNotifier queue, String pageId) { + final op = queue.state + .queuedByEntityKey[EntitySyncKey.pageDescriptor(pageId)]?.pending.op; + return op is PagePatchOp ? op.payload['isAttack'] as bool? : null; + } + + test('a second toggle supersedes the first while it is still queued', + () async { + final (container, queue) = await openTwoPages(); + final notifier = container.read(strategyProvider.notifier); + + await notifier.switchSide(allPages: true); + expect(queuedSideOf(queue, 'page-b'), isFalse); + + // The server has not taken the first change yet: page B still reads + // Attack remotely, the side this toggle asks for. + await notifier.switchSide(allPages: true); + + expect(container.read(mapProvider).isAttack, isTrue); + expect(queuedSideOf(queue, 'page-b'), isTrue); + await _settle(); + }); + + test('a second toggle follows a side change that is in flight', () async { + final (container, queue) = await openTwoPages(); + final notifier = container.read(strategyProvider.notifier); + const key = EntitySyncKey.pageDescriptor('page-b'); + + await notifier.switchSide(allPages: true); + final sent = queue.state.queuedByEntityKey[key]!.pending.op; + queue.state = queue.state.copyWith( + queuedByEntityKey: const {}, + ); + queue.holdInFlight(key, sent); + + await notifier.switchSide(allPages: true); + + expect(queuedSideOf(queue, 'page-b'), isTrue); + await _settle(); + }); + + test('overlapping toggles reconcile across unpublished durable writes', + () async { + final first = _page('page-a', 0, revision: 4); + final second = _page('page-b', 1, revision: 7); + final third = _page('page-c', 2, revision: 9); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [first, second, third], + activePage: _pageSnapshot(first), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + final notifier = container.read(strategyProvider.notifier); + + queue.writeGate = Completer(); + final toDefense = notifier.switchSide(allPages: true); + // Starts before the first toggle's writes have published anything. + final backToAttack = notifier.switchSide(allPages: true); + queue.writeGate!.complete(); + await Future.wait([toDefense, backToAttack]); + + expect(container.read(mapProvider).isAttack, isTrue); + expect(queuedSideOf(queue, 'page-b'), isTrue); + expect(queuedSideOf(queue, 'page-c'), isTrue); + await _settle(); + }); + }); + + group('lineup history through cloud sync', () { + const placedAt = Offset(100, 100); + late _FakeStrategyOpQueueNotifier queue; + late _FakeServer server; + late _ServerRepository repository; + + /// Page settings that round-trip exactly, so live sync authors nothing + /// for them; the agent size marks which server revision is on screen. + CloudPayload settingsFor(int contentRevision) => + StrategySettings(agentSize: 30 + contentRevision.toDouble()).toJson(); + + /// Opens page-1 with the lineup [rows] the server holds. + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> + openEmpty([List rows = const []]) async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: rows); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: + _pageSnapshot(page, settings: settingsFor(1), lineups: server.rows), + )); + queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, remote, page); + } + + /// Places a lineup the way the editor does. + LineUpLink place(ProviderContainer container) { + final lineUps = container.read(lineUpProvider.notifier)..startFresh(); + lineUps.setDraftAgent(PlacedAgent( + id: 'agent-x', + type: AgentType.sova, + position: placedAt, + )); + lineUps.setDraftAbility(PlacedAbility( + id: 'ability-x', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: const Offset(300, 300), + )); + return lineUps.commitPlacement()!; + } + + Map desiredOps( + ProviderContainer container, + RemotePage page, + ) { + return container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ) ?? + const {}; + } + + RemoteEditorSnapshot serverSnapshot( + RemotePage page, + List lineups, + int contentRevision, { + List? pages, + }) { + return _editorSnapshot( + pages: pages ?? [page], + activePage: _pageSnapshot( + page, + settings: settingsFor(contentRevision), + contentRevision: contentRevision, + lineups: lineups, + ), + ); + } + + Future settleSync( + ProviderContainer container, int contentRevision) async { + // Ack reconciliation refreshes, re-syncs and rehydrates in turn. + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect( + container.read(strategySettingsProvider).agentSize, + 30 + contentRevision, + reason: 'the page rehydrated from the server', + ); + } + + Map queuedOps() => { + for (final entry in queue.state.queuedByEntityKey.entries) + entry.key: entry.value.pending.op, + }; + + /// The lineup writes in [ops]. + Map lineupOps( + Map ops) => + { + for (final entry in ops.entries) + if (entry.key.kind == EntitySyncKeyKind.lineup) + entry.key: entry.value, + }; + + /// The key of lineup group [id]'s row. + EntitySyncKey keyOf(RemotePage page, String id) => + EntitySyncKey.lineup(page.publicId, id); + + /// The group lineup or spot [id] was last drawn from or written to. + String groupOf(ProviderContainer container, RemotePage page, String id) => + container + .read(activePageLiveSyncProvider.notifier) + .lineupGroupOf(page.publicId, id)!; + + /// The id a group of lineups [ids], new together, is named after. + String firstOf(Iterable ids) => + ids.reduce((a, b) => a.compareTo(b) <= 0 ? a : b); + + /// Every row the server holds reads back whole: each lineup in it is + /// drawn, from that row. + void expectEveryRowDrawn(List lineups) { + final drawn = lineUpGraphFromCloudRows( + [for (final row in lineups) CloudLineupRow.remote(row)]); + expect( + {for (final link in drawn.graph.links) link.id: drawn.groupOf[link.id]}, + { + for (final MapEntry(key: group, value: held) + in _groupsOf(lineups).entries) + for (final id in held['links']!) id: group, + }, + ); + } + + /// The server answers [sent]; the session's refresh after the answers + /// reads the server as it then is, at [contentRevision]. [teammate] + /// edits every live row's lineup data in the same round, as a teammate's + /// change landing alongside ours would. One queue update then takes the + /// answers (which also marks the page saved once nothing is left). + void send( + _FakeRemoteEditorNotifier remote, + RemotePage page, + Map sent, { + required int contentRevision, + void Function(Map data)? teammate, + }) { + final acks = server.applyBatch(sent.values); + if (teammate != null) { + for (final row in server.liveRows) { + server.teammateEdit(row.publicId, teammate); + } + } + expectEveryRowDrawn(server.liveRows); + remote.initialSnapshot = + serverSnapshot(page, server.rows, contentRevision); + queue.answer(sent, acks); + } + + /// The edits' scheduled sync queues ops; the server answers all of them + /// ([send]), and the session reconciles the answers, refreshes from the + /// server and rehydrates, as in production. Returns the live rows the + /// server holds. + Future> land( + ProviderContainer container, + _FakeRemoteEditorNotifier remote, + RemotePage page, { + required int contentRevision, + void Function(Map data)? teammate, + }) async { + await _settle(); + send(remote, page, queuedOps(), + contentRevision: contentRevision, teammate: teammate); + await settleSync(container, contentRevision); + return server.liveRows; + } + + /// Every lineup write is one whole group row, keyed by its group. + void expectLineupRows(Map ops) { + for (final op in ops.values) { + final payload = switch (op) { + LineupAddOp(:final payload) => payload, + LineupPatchOp(:final payload) => payload, + _ => null, + }; + if (payload == null) continue; + expect(payload['kind'], cloudLineupsPayloadKind, reason: '$op'); + expect(cloudPayloadData(payload)['id'], op.entityPublicId, + reason: '$op'); + } + } + + test('undoing a details edit made before the first hydration', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + container + .read(lineUpProvider.notifier) + .updateLink(link.copyWith(notes: 'jump throw')); + await land(container, remote, page, contentRevision: 2); + + container.read(actionProvider.notifier).undoAction(); + + final lineUps = container.read(lineUpProvider); + expect(lineUps.links.single.notes, ''); + expect(lineUps.landingById(lineUps.links.single.landingId), isNotNull); + expect(lineUps.landings, hasLength(1)); + final afterUndo = desiredOps(container, page); + expectLineupRows(afterUndo); + expect(afterUndo.values.whereType(), isEmpty); + expect(afterUndo.values.whereType(), hasLength(1)); + expect(afterUndo.keys.single, keyOf(page, link.id)); + await _settle(); + }); + + test('a landing keeps its id through sync and hydration', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + container + .read(lineUpProvider.notifier) + .updateLandingPosition(link.landingId, const Offset(320, 340)); + await land(container, remote, page, contentRevision: 2); + + expect(container.read(lineUpProvider).landings.single.id, link.landingId); + + // The pre-hydration landing move still undoes, onto the same landing. + container.read(actionProvider.notifier).undoAction(); + expect( + container.read(lineUpProvider).landings.single.ability.position, + const Offset(300, 300), + ); + // Undoing the placement removes the whole lineup, leaving no node. + container.read(actionProvider.notifier).undoAction(); + final empty = container.read(lineUpProvider); + expect(empty.links, isEmpty); + expect(empty.origins, isEmpty); + expect(empty.landings, isEmpty); + await _settle(); + }); + + test('undoing a move keeps a teammate weapon on the same origin', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + container + .read(lineUpProvider.notifier) + .updateOriginAgentPosition(link.originId, const Offset(500, 500)); + await land(container, remote, page, contentRevision: 3); + + // Then a teammate arms the same origin. + server.teammateEdit( + link.id, _eachAgent((agent) => agent['weapon'] = 'vandal')); + remote.setSnapshot(serverSnapshot(page, server.rows, 4)); + await settleSync(container, 4); + expect( + container.read(lineUpProvider).originById(link.originId)!.agent.weapon, + WeaponType.vandal, + ); + + container.read(actionProvider.notifier).undoAction(); + + final origin = container.read(lineUpProvider).originById(link.originId)!; + expect(origin.agent.position, placedAt); + expect(origin.agent.weapon, WeaponType.vandal); + final patch = + desiredOps(container, page).values.whereType().single; + final sent = + _entry(cloudPayloadData(patch.payload), 'origins', link.originId); + expect((sent['agent'] as Map)['weapon'], 'vandal'); + await _settle(); + }); + + test('an edit before a deletion stays undoable after the deletion lands', + () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + final lineUps = container.read(lineUpProvider.notifier); + const moved = Offset(500, 500); + + lineUps.updateOriginAgentPosition(link.originId, moved); + lineUps.deleteOrigin(link.originId); + final after = await land(container, remote, page, contentRevision: 3); + expect(after, isEmpty); + // The server keeps the deleted lineup as a tombstone. + expect(server.row(link.id).deleted, isTrue); + + final history = container.read(actionProvider.notifier); + Offset? originPosition() => container + .read(lineUpProvider) + .originById(link.originId) + ?.agent + .position; + + history.undoAction(); + expect(originPosition(), moved); + history.undoAction(); + expect(originPosition(), placedAt); + history.redoAction(); + expect(originPosition(), moved); + history.redoAction(); + expect(originPosition(), isNull); + await _settle(); + }); + + /// Places lineup A, then B from a second origin into A's landing, each + /// link named, the way the editor does. + (LineUpLink, LineUpLink) placeFanIn(ProviderContainer container) { + final a = place(container); + final lineUps = container.read(lineUpProvider.notifier) + ..updateLink(a.copyWith(name: 'From heaven')) + ..startToLanding(a.landingId); + lineUps.setDraftAgent(PlacedAgent( + id: 'agent-y', + type: AgentType.sova, + position: const Offset(150, 150), + )); + final b = lineUps.commitPlacement(name: 'From mid')!; + expect(b.landingId, a.landingId, reason: 'B fans in to A\'s landing'); + return (container.read(lineUpProvider).linkById(a.id)!, b); + } + + /// Places lineup A, then B from A's origin to a second landing, each + /// link named, the way the editor does. + (LineUpLink, LineUpLink) placeFanOut(ProviderContainer container) { + final a = place(container); + final lineUps = container.read(lineUpProvider.notifier) + ..updateLink(a.copyWith(name: 'Short')) + ..startFromOrigin(a.originId); + lineUps.setDraftAbility(PlacedAbility( + id: 'ability-y', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: const Offset(450, 450), + )); + final b = lineUps.commitPlacement(name: 'Long')!; + expect(b.originId, a.originId, reason: 'B fans out from A\'s origin'); + return (container.read(lineUpProvider).linkById(a.id)!, b); + } + + List<(String, String, String, String)> linkShape(LineUpState state) => [ + for (final link in state.links) + (link.id, link.originId, link.landingId, link.name), + ]; + + test('a fan-in lineup keeps its shared landing, ids and names', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanIn(container); + final before = container.read(lineUpProvider); + final originIds = before.origins.map((origin) => origin.id).toList(); + + final lineups = await land(container, remote, page, contentRevision: 2); + + // One row for the group, holding the landing once, named after its + // smaller lineup id. + final group = firstOf([a.id, b.id]); + expect(_groupsOf(lineups), { + group: { + 'origins': [a.originId, b.originId], + 'landings': [a.landingId], + 'links': [a.id, b.id], + }, + }); + expect(groupOf(container, page, b.id), group); + + // Hydration draws the shared landing once. + final hydrated = container.read(lineUpProvider); + expect(hydrated.origins.map((origin) => origin.id), originIds); + expect(hydrated.landings.single.id, a.landingId); + expect(linkShape(hydrated), [ + (a.id, a.originId, a.landingId, 'From heaven'), + (b.id, b.originId, a.landingId, 'From mid'), + ]); + expect(desiredOps(container, page), isEmpty); + await _settle(); + }); + + test('a fan-out lineup keeps its shared origin, ids and names', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanOut(container); + final landingIds = container + .read(lineUpProvider) + .landings + .map((landing) => landing.id) + .toList(); + + final lineups = await land(container, remote, page, contentRevision: 2); + + expect(_groupsOf(lineups), { + firstOf([a.id, b.id]): { + 'origins': [a.originId], + 'landings': [a.landingId, b.landingId], + 'links': [a.id, b.id], + }, + }); + + final hydrated = container.read(lineUpProvider); + expect(hydrated.origins.single.id, a.originId); + expect(hydrated.origins.single.agent.position, placedAt); + expect(hydrated.landings.map((landing) => landing.id), landingIds); + expect(linkShape(hydrated), [ + (a.id, a.originId, a.landingId, 'Short'), + (b.id, a.originId, b.landingId, 'Long'), + ]); + expect(desiredOps(container, page), isEmpty); + await _settle(); + }); + + test('moving a shared spot patches its group and nothing else', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanIn(container); + final other = place(container); + var lineups = await land(container, remote, page, contentRevision: 2); + final group = firstOf([a.id, b.id]); + // The group's row moves on to revision 2; the other's stays at 1. + container.read(lineUpProvider.notifier).updateLink( + container.read(lineUpProvider).linkById(a.id)!.copyWith(notes: 'x')); + lineups = await land(container, remote, page, contentRevision: 3); + expect( + {for (final row in lineups) row.publicId: row.revision}, + {group: 2, other.id: 1}, + ); + + const moved = Offset(320, 340); + container + .read(lineUpProvider.notifier) + .updateLandingPosition(a.landingId, moved); + + // One patch, of the group, against its revision, carrying the moved + // landing once for both its lineups. + final ops = desiredOps(container, page); + expectLineupRows(ops); + expect(ops.keys, [keyOf(page, group)]); + final patch = ops.values.single as LineupPatchOp; + expect(patch.expectedLineupRevision, 2); + final landing = + _entry(cloudPayloadData(patch.payload), 'landings', a.landingId); + expect((landing['ability'] as Map)['position'], + {'dx': moved.dx, 'dy': moved.dy}); + + lineups = await land(container, remote, page, contentRevision: 4); + expect( + {for (final row in lineups) row.publicId: row.revision}, + {group: 3, other.id: 1}, + ); + Offset landingAt(RemoteLineup row) { + final ability = _entries(cloudPayloadData(row.payload), 'landings') + .single['ability'] as Map; + final position = ability['position'] as Map; + return Offset((position['dx'] as num).toDouble(), + (position['dy'] as num).toDouble()); + } + + expect( + {for (final row in lineups) row.publicId: landingAt(row)}, + {group: moved, other.id: const Offset(300, 300)}, + ); + final hydrated = container.read(lineUpProvider); + expect(hydrated.landingById(a.landingId)!.ability.position, moved); + expect(hydrated.landings, hasLength(2)); + expect(desiredOps(container, page), isEmpty); + await _settle(); + }); + + test('undoing a deletion rejoins the surviving shared landing', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanIn(container); + var lineups = await land(container, remote, page, contentRevision: 2); + final group = firstOf([a.id, b.id]); + container.read(lineUpProvider.notifier).deleteOrigin(a.originId); + lineups = await land(container, remote, page, contentRevision: 3); + // The group keeps its row, now holding B alone. + expect(_groupsOf(lineups), { + group: { + 'origins': [b.originId], + 'landings': [a.landingId], + 'links': [b.id], + }, + }); + + container.read(actionProvider.notifier).undoAction(); + + final restored = container.read(lineUpProvider); + expect(restored.landings.single.id, a.landingId); + expect(linkShape(restored), [ + (b.id, b.originId, a.landingId, 'From mid'), + (a.id, a.originId, a.landingId, 'From heaven'), + ]); + // A patch of the group's row, expecting its revision. + final ops = desiredOps(container, page); + expectLineupRows(ops); + expect(ops.keys, [keyOf(page, group)]); + expect( + (ops.values.single as LineupPatchOp).expectedLineupRevision, + server.row(group).revision, + ); + lineups = await land(container, remote, page, contentRevision: 4); + expect(_groupsOf(lineups)[group]!['links'], [b.id, a.id]); + expect(_groupsOf(lineups)[group]!['landings'], [a.landingId]); + expect(server.row(group).revision, 3); + expect(linkShape(container.read(lineUpProvider)), hasLength(2)); + await _settle(); + }); + + test( + 'deleting a lineup takes the spots nothing else uses and keeps a ' + 'shared one', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanIn(container); + var lineups = await land(container, remote, page, contentRevision: 2); + final group = firstOf([a.id, b.id]); + + container.read(lineUpProvider.notifier).deleteLink(a.id); + + // A's origin went with it; the landing B still uses stayed. + var graph = container.read(lineUpProvider).graph; + expect(graph.origins.map((origin) => origin.id), [b.originId]); + expect(graph.landings.map((landing) => landing.id), [a.landingId]); + // The group's row is patched to hold B alone. + final ops = desiredOps(container, page); + expect(ops.keys, [keyOf(page, group)]); + expect(ops.values.single, isA()); + lineups = await land(container, remote, page, contentRevision: 3); + expect(_groupsOf(lineups), { + group: { + 'origins': [b.originId], + 'landings': [a.landingId], + 'links': [b.id], + }, + }); + graph = container.read(lineUpProvider).graph; + expect(graph.links.map((link) => link.id), [b.id]); + expect(graph.landings.map((landing) => landing.id), [a.landingId]); + + // Deleting the group's last lineup deletes its row, landing and all. + container.read(lineUpProvider.notifier).deleteLink(b.id); + graph = container.read(lineUpProvider).graph; + expect(graph.origins, isEmpty); + expect(graph.landings, isEmpty); + expect(desiredOps(container, page).values.single, isA()); + lineups = await land(container, remote, page, contentRevision: 4); + expect(lineups, isEmpty); + expect(server.row(group).deleted, isTrue); + expect(container.read(lineUpProvider).graph.landings, isEmpty); + await _settle(); + }); + + test( + 'a teammate editing the other lineup of a group first makes my edit ' + 'a conflict that overwrites neither', () async { + final (container, remote, page) = await openEmpty(); + final (a, b) = placeFanIn(container); + await land(container, remote, page, contentRevision: 2); + final group = firstOf([a.id, b.id]); + final key = keyOf(page, group); + container + .read(lineUpProvider.notifier) + .updateLink(container.read(lineUpProvider).linkById(a.id)!.copyWith( + notes: 'jump throw', + )); + await _settle(); + // The group's row is written, against the revision both started from. + final mine = queuedOps(); + expect( + mine.keys.where((key) => key.kind == EntitySyncKeyKind.lineup), + [key], + ); + expect((mine[key]! as LineupPatchOp).expectedLineupRevision, 1); + + // A teammate's edit to B, in the same group, lands first. + server.teammateEdit(group, + (data) => _entry(data, 'links', b.id)['notes'] = 'teammate notes'); + send(remote, page, queuedOps(), contentRevision: 3); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // Refused: the server keeps the teammate's version, and mine waits. + expect( + queue.state.lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having((ack) => ack.rejectionReason, 'reason', + OpRejectionReason.revisionMismatch), + ); + final stored = server.row(group); + expect(stored.revision, 2); + expect(_linkIn(stored.payload, b.id)['notes'], 'teammate notes'); + expect(_linkIn(stored.payload, a.id)['notes'], ''); + expect( + container.read(lineUpProvider).linkById(a.id)!.notes, 'jump throw'); + expect(queue.state.attentionByEntityKey.keys, [key]); + expect(container.read(strategyConflictProvider), hasLength(1)); + await _settle(); + }); + + test('a stale edit to a lineup a teammate changed conflicts', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + container + .read(lineUpProvider.notifier) + .updateLink(link.copyWith(notes: 'mine')); + await _settle(); + final key = keyOf(page, link.id); + final mine = queuedOps()[key]! as LineupPatchOp; + expect(mine.expectedLineupRevision, 1); + + // A teammate's notes edit to the same lineup lands first. + server.teammateEdit( + link.id, _eachLink((link) => link['notes'] = 'theirs')); + remote.setSnapshot(serverSnapshot(page, server.rows, 3)); + await _settle(); + // Still checked against the version the user saw: never re-based onto + // theirs, which would overwrite it in silence. + expect( + (queuedOps()[key]! as LineupPatchOp).expectedLineupRevision, + 1, + ); + + // The server refuses it; the user sees their work and a conflict. + send(remote, page, queuedOps(), contentRevision: 3); + await _settle(); + expect( + queue.state.lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having((ack) => ack.rejectionReason, 'reason', + OpRejectionReason.revisionMismatch), + ); + expect(_onlyLinkIn(server.row(link.id).payload)['notes'], 'theirs'); + expect(container.read(lineUpProvider).links.single.notes, 'mine'); + expect(queue.state.needsAttention, isTrue); + expect(queue.state.attentionByEntityKey.keys, [key]); + expect(container.read(strategyConflictProvider), hasLength(1)); + + // Using theirs shows the teammate's notes and sends nothing. + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + expect(resolved, isTrue); + expect(container.read(lineUpProvider).links.single.notes, 'theirs'); + expect(queue.state.attentionByEntityKey, isEmpty); + expect( + queuedOps().keys.where((key) => key.kind == EntitySyncKeyKind.lineup), + isEmpty, + ); + }); + + test( + 'deleting a spot while a teammate adds a lineup to its group is ' + 'refused, and theirs stays whole', () async { + final (container, remote, page) = await openEmpty(); + final first = place(container); + await land(container, remote, page, contentRevision: 2); + final key = keyOf(page, first.id); + + // A teammate's lineup from the same origin lands in the origin's + // group; this canvas has not drawn it yet. + server.teammateEdit(first.id, (data) { + (data['landings'] as List) + .add(_landingJson('their-landing', const Offset(80, 80))); + (data['links'] as List).add(_linkJson('their-link', + originId: first.originId, + landingId: 'their-landing', + name: 'Theirs')); + }); + + // The user deletes the origin, which takes their only lineup on it: + // the group, as they see it, is gone. + container.read(lineUpProvider.notifier).deleteOrigin(first.originId); + await _settle(); + final sent = queuedOps(); + expect(lineupOps(sent).keys, [key]); + expect(lineupOps(sent).values.single, isA()); + + // The server refuses the delete: the group changed since. Their + // lineup is untouched and the user's delete waits. + send(remote, page, sent, contentRevision: 3); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(server.row(first.id).deleted, isFalse); + expect(server.row(first.id).revision, 2); + expect(queue.state.attentionByEntityKey.keys, [key]); + + // Taking the cloud's version draws their lineup whole, on the origin. + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _settle(); + expect(resolved, isTrue); + final graph = container.read(lineUpProvider).graph; + expect(graph.links.map((link) => link.id), [first.id, 'their-link']); + expect(graph.origins.map((origin) => origin.id), [first.originId]); + expect(graph.origins.single.agent.position, placedAt); + expect(graph.landings.map((landing) => landing.id), + [first.landingId, 'their-landing']); + expect(queue.state.attentionByEntityKey, isEmpty); + expect(desiredOps(container, page), isEmpty); + await _settle(); + }); + + test( + 'a lineup placed from an origin whose group a teammate deleted ' + 'meanwhile waits in attention, nothing lost', () async { + final (container, remote, page) = await openEmpty(); + final first = place(container); + await land(container, remote, page, contentRevision: 2); + final key = keyOf(page, first.id); + + // The user starts a new lineup from the landed origin... + final lineUps = container.read(lineUpProvider.notifier) + ..startFromOrigin(first.originId); + lineUps.setDraftAbility(PlacedAbility( + id: 'ability-y', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: const Offset(500, 500), + )); + // ...while a teammate deletes that lineup's group, origin included. + // The placement holds the group, so the canvas keeps it as it was. + server.teammateDelete(first.id); + remote.setSnapshot(serverSnapshot(page, server.rows, 3)); + await _settle(); + expect(container.read(lineUpProvider).linkById(first.id), isNotNull); + expect(desiredOps(container, page), isNot(contains(key))); + + final second = lineUps.commitPlacement()!; + await _settle(); + + // The new lineup joins the origin's group: the group's row, as the user + // has it, against the revision they saw, not the teammate's tombstone. + final queued = lineupOps(queuedOps()); + expect(groupOf(container, page, second.id), first.id); + expect(queued.keys, [key]); + final patch = queued.values.single as LineupPatchOp; + expect(patch.expectedLineupRevision, 1); + final data = cloudPayloadData(patch.payload); + expect(_entries(data, 'origins').single['id'], first.originId); + expect(_entries(data, 'links').map((link) => link['id']), + [first.id, second.id]); + + // The server refuses it as deleted: the teammate's delete stands, and + // the user's lineups wait, still on screen, for them to choose. + send(remote, page, queuedOps(), contentRevision: 4); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect( + queue.state.lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having( + (ack) => ack.rejectionReason, 'reason', OpRejectionReason.deleted), + ); + expect(server.row(first.id).deleted, isTrue); + expect(server.liveRows, isEmpty); + expect(queue.state.attentionByEntityKey.keys, [key]); + expect(container.read(lineUpProvider).links.map((link) => link.id), + [first.id, second.id]); + await _settle(); + }); + + test('each lineup action undoes after hydration, one write at a time', + () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + var contentRevision = 2; + var lineups = await land(container, remote, page, + contentRevision: contentRevision++); + Future landNext() async { + lineups = await land(container, remote, page, + contentRevision: contentRevision++); + } + + final lineUps = container.read(lineUpProvider.notifier); + LineUpState state() => container.read(lineUpProvider); + lineUps.updateLink(state().linkById(link.id)!.copyWith(name: 'Heaven')); + await landNext(); + lineUps.updateOriginAgentPosition(link.originId, const Offset(500, 500)); + await landNext(); + lineUps.updateLandingPosition(link.landingId, const Offset(320, 340)); + await landNext(); + lineUps.updateLandingAbilityVisualState( + landingId: link.landingId, + visualState: state() + .landingById(link.landingId)! + .ability + .visualState + .copyWith(showRangeOutline: false), + ); + await landNext(); + lineUps.setOriginWeapon(link.originId, WeaponType.vandal); + await landNext(); + expect(state().linkById(link.id)!.name, 'Heaven'); + + final key = keyOf(page, link.id); + final history = container.read(actionProvider.notifier); + // Undoes one step and lands it; returns the one write it made. + Future undoAndLand() async { + history.undoAction(); + final ops = desiredOps(container, page); + expectLineupRows(ops); + expect(ops.keys, [key]); + await landNext(); + return ops.values.single; + } + + expect(await undoAndLand(), isA()); + expect(state().originById(link.originId)!.agent.weapon, + isNot(WeaponType.vandal)); + expect(await undoAndLand(), isA()); + expect( + state() + .landingById(link.landingId)! + .ability + .visualState + .showRangeOutline, + isTrue, + ); + expect(await undoAndLand(), isA()); + expect(state().landingById(link.landingId)!.ability.position, + const Offset(300, 300)); + expect(await undoAndLand(), isA()); + expect(state().originById(link.originId)!.agent.position, placedAt); + expect(await undoAndLand(), isA()); + expect(state().linkById(link.id)!.name, ''); + expect(await undoAndLand(), isA()); + expect(state().links, isEmpty); + expect(lineups, isEmpty); + + history.redoAction(); + expect(state().links.single.id, link.id); + expect(state().landings.single.id, link.landingId); + expect(state().origins.single.id, link.originId); + await _settle(); + }); + + test('undoing a notes edit keeps an image a teammate added', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + container + .read(lineUpProvider.notifier) + .updateLink(link.copyWith(notes: 'jump throw')); + await land( + container, + remote, + page, + contentRevision: 3, + teammate: _eachLink((link) { + link['images'] = [ + {'id': 'teammate-image', 'fileExtension': '.png'}, + ]; + }), + ); + expect( + container.read(lineUpProvider).links.single.images.map((i) => i.id), + ['teammate-image'], + ); + + container.read(actionProvider.notifier).undoAction(); + + final restored = container.read(lineUpProvider).links.single; + expect(restored.notes, ''); + expect(restored.images.map((image) => image.id), ['teammate-image']); + final patch = + desiredOps(container, page).values.whereType().single; + final item = _linkIn(patch.payload, link.id); + expect(patch.lineupPublicId, link.id); + expect(item['notes'], ''); + expect( + (item['images'] as List).map((image) => (image as Map)['id']), + ['teammate-image'], + ); + await _settle(); + }); + + List imageIds(ProviderContainer container) => container + .read(lineUpProvider) + .links + .single + .images + .map((image) => image.id) + .toList(); + + List images(List ids) => [ + for (final id in ids) SimpleImageData(id: id, fileExtension: '.png'), + ]; + + test('undo and redo of image removals keep the image order', () async { + final (container, remote, page) = await openEmpty(); + final placed = place(container); + final lineUps = container.read(lineUpProvider.notifier); + lineUps.updateLink(placed.copyWith(images: images(['a', 'b', 'c', 'd']))); + await land(container, remote, page, contentRevision: 2); + // Remove the first image, then two from the middle. + lineUps.updateLink(container + .read(lineUpProvider) + .links + .single + .copyWith(images: images(['b', 'c', 'd']))); + lineUps.updateLink(container + .read(lineUpProvider) + .links + .single + .copyWith(images: images(['d']))); + await land(container, remote, page, contentRevision: 3); + final history = container.read(actionProvider.notifier); + + history.undoAction(); + expect(imageIds(container), ['b', 'c', 'd']); + history.undoAction(); + expect(imageIds(container), ['a', 'b', 'c', 'd']); + final patch = + desiredOps(container, page).values.whereType().single; + expect( + (_onlyLinkIn(patch.payload)['images'] as List) + .map((image) => (image as Map)['id']), + ['a', 'b', 'c', 'd'], + ); + history.redoAction(); + expect(imageIds(container), ['b', 'c', 'd']); + history.redoAction(); + expect(imageIds(container), ['d']); + history.undoAction(); + history.undoAction(); + expect(imageIds(container), ['a', 'b', 'c', 'd']); + await _settle(); + }); + + test('undoing an image removal keeps an image a teammate added between', + () async { + final (container, remote, page) = await openEmpty(); + final placed = place(container); + final lineUps = container.read(lineUpProvider.notifier); + lineUps.updateLink(placed.copyWith(images: images(['a', 'b', 'c']))); + await land(container, remote, page, contentRevision: 2); + lineUps.updateLink(container + .read(lineUpProvider) + .links + .single + .copyWith(images: images(['a', 'c']))); + // Our removal of b lands, and a teammate adds x right after a. + await land( + container, + remote, + page, + contentRevision: 3, + teammate: _eachLink((link) { + (link['images'] as List) + .insert(1, {'id': 'x', 'fileExtension': '.png'}); + }), + ); + expect(imageIds(container), ['a', 'x', 'c']); + final history = container.read(actionProvider.notifier); + + history.undoAction(); + expect(imageIds(container), ['a', 'x', 'b', 'c']); + history.redoAction(); + expect(imageIds(container), ['a', 'x', 'c']); + history.undoAction(); + expect(imageIds(container), ['a', 'x', 'b', 'c']); + await _settle(); + }); + + test('undoing a visibility toggle keeps a teammate toggle', () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + final landing = container.read(lineUpProvider).landings.single; + container.read(lineUpProvider.notifier).updateLandingAbilityVisualState( + landingId: link.landingId, + visualState: + landing.ability.visualState.copyWith(showRangeOutline: false), + ); + await land( + container, + remote, + page, + contentRevision: 3, + teammate: _eachAbility((ability) { + (ability['visualState'] as Map)['showRangeFill'] = false; + }), + ); + + container.read(actionProvider.notifier).undoAction(); + + final visual = container + .read(lineUpProvider) + .landingById(link.landingId)! + .ability + .visualState; + expect(visual.showRangeOutline, isTrue); + expect(visual.showRangeFill, isFalse); + await _settle(); + }); + + test('an ack refresh that already holds a teammate change shows it', + () async { + final (container, remote, page) = await openEmpty(); + final link = place(container); + await land(container, remote, page, contentRevision: 2); + const moved = Offset(500, 500); + container + .read(lineUpProvider.notifier) + .updateOriginAgentPosition(link.originId, moved); + // Our move lands and, in the same server state, a teammate arms it. + await land( + container, + remote, + page, + contentRevision: 3, + teammate: _eachAgent((agent) => agent['weapon'] = 'vandal'), + ); + + final origin = container.read(lineUpProvider).originById(link.originId)!; + expect(origin.agent.position, moved); + expect(origin.agent.weapon, WeaponType.vandal); + final ops = desiredOps(container, page); + expect(ops[keyOf(page, link.id)], isNull); + await _settle(); + }); + + test('an ack while another page is active does not revive the old lineup', + () async { + final first = _page('page-1', 0); + final second = _page('page-2', 1); + final secondSnapshot = _pageSnapshot(second, settings: settingsFor(9)); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot( + pages: [first, second], + activePage: _pageSnapshot(first, settings: settingsFor(1)), + ), + pageCatalog: { + first.publicId: _pageSnapshot(first, settings: settingsFor(1)), + second.publicId: secondSnapshot, + }, + ); + queue = _FakeStrategyOpQueueNotifier(); + server = _FakeServer(first.publicId); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + final link = place(container); + await _settle(); + var ops = queuedOps(); + var acks = server.applyBatch(ops.values); + remote.initialSnapshot = + serverSnapshot(first, server.rows, 2, pages: [first, second]); + remote.pageCatalog[first.publicId] = remote.initialSnapshot.activePage!; + queue.answer(ops, acks); + await settleSync(container, 2); + + const moved = Offset(500, 500); + container + .read(lineUpProvider.notifier) + .updateOriginAgentPosition(link.originId, moved); + await session.setActivePage(second.publicId); + await _settle(); + expect(container.read(strategySettingsProvider).agentSize, 39); + + // Page 1's move lands with a teammate's weapon change while page 2 is + // on screen. + ops = queuedOps(); + expect(ops.keys.where((key) => key.pageId == first.publicId), isNotEmpty); + acks = server.applyBatch(ops.values); + server.teammateEdit( + link.id, _eachAgent((agent) => agent['weapon'] = 'vandal')); + remote.pageCatalog[first.publicId] = _pageSnapshot( + first, + settings: settingsFor(3), + contentRevision: 3, + lineups: server.rows, + ); + remote.initialSnapshot = _editorSnapshot( + pages: [first, second], + activePage: secondSnapshot, + ); + queue.answer(ops, acks); + await settleSync(container, 9); + + await session.setActivePage(first.publicId); + await settleSync(container, 3); + + final origin = container.read(lineUpProvider).originById(link.originId)!; + expect(origin.agent.position, moved); + expect(origin.agent.weapon, WeaponType.vandal); + expect( + desiredOps(container, first)[keyOf(first, link.id)], + isNull, + ); + await _settle(); + }); + + const newer = Offset(330, 330); + + /// Lineups A ('Heaven') and B ('Mid') from their own origins into one + /// landing at [newer]: one group, its row `link-a` at revision 7. + RemoteLineup fanIn(String pageId) => _groupRow( + pageId, + 'link-a', + origins: [ + _originJson('origin-a'), + _originJson('origin-b', const Offset(60, 20)), + ], + landings: [_landingJson('landing', newer)], + links: [ + _linkJson('link-a', + originId: 'origin-a', landingId: 'landing', name: 'Heaven'), + _linkJson('link-b', + originId: 'origin-b', landingId: 'landing', name: 'Mid'), + ], + revision: 7, + ); + + /// Each lineup drawn, with the spot ids it is attached to. + List<(String, String, String)> drawnLinks(ProviderContainer container) => [ + for (final link in container.read(lineUpProvider).links) + (link.id, link.originId, link.landingId), + ]; + + test('opening a page with a shared landing writes no lineup', () async { + final (container, remote, page) = await openEmpty([fanIn('page-1')]); + await _settle(); + expect(container.read(lineUpProvider).landings.map((l) => l.id), + ['landing']); + expect(drawnLinks(container), [ + ('link-a', 'origin-a', 'landing'), + ('link-b', 'origin-b', 'landing'), + ]); + expect(lineupOps(queuedOps()), isEmpty); + + // Nor does the next hydration. + remote.setSnapshot(serverSnapshot(page, server.rows, 2)); + await settleSync(container, 2); + expect(lineupOps(queuedOps()), isEmpty); + expect(server.received, isEmpty); + }); + + /// The live read of the page [openOnRealQueue] opened. + late _FakeRemoteEditorNotifier liveRead; + + /// Shows the page as [server] now holds it, as the live read would. + late void Function() showServer; + + /// Has the live read show [pageId] from now on, as it does once the + /// session selects that page. + late void Function(String pageId) readPage; + + /// Opens [page] on the real queue, its outbox kept in [store], which + /// sends to [server] through [repository] and reads it back after every + /// batch. A batch waits for [hold] while it is set. The queue reaches + /// the server while [_online] holds. Returns the container and the + /// batches sent. + Future<(ProviderContainer, List>)> openOnRealQueue( + RemotePage page, { + DurableStrategyOutboxStore? store, + Completer? hold, + List otherPages = const [], + }) async { + final others = { + for (final other in otherPages) + other.publicId: _pageSnapshot(other, settings: settingsFor(1)), + }; + var shown = page.publicId; + RemoteEditorSnapshot read() => _editorSnapshot( + pages: [page, ...otherPages], + activePage: others[shown] ?? + _pageSnapshot( + page, + settings: settingsFor(1), + elements: server.elements, + lineups: server.rows, + ), + ); + final remote = liveRead = _FakeRemoteEditorNotifier(read(), pageCatalog: { + page.publicId: read().activePage!, + ...others, + }); + void reread() { + final shownBefore = shown; + shown = page.publicId; + remote.pageCatalog[page.publicId] = read().activePage!; + shown = shownBefore; + remote.initialSnapshot = read(); + } + + showServer = () { + reread(); + remote.setSnapshot(remote.initialSnapshot); + }; + readPage = (pageId) { + shown = pageId; + reread(); + }; + repository = _ServerRepository(server, afterBatch: reread)..hold = hold; + final container = ProviderContainer(overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + durableStrategyOutboxStoreProvider + .overrideWithValue(store ?? MemoryDurableStrategyOutboxStore()), + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(_CloudReadyAuthProvider.new), + convexConnectionSnapshotProvider + .overrideWith((ref) => ref.watch(_online)), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + cloudMediaUploadQueueProvider + .overrideWith(() => _RecordingMediaQueue()), + ]); + addTearDown(container.dispose); + container.listen(strategyPageSessionProvider, (_, __) {}); + await container.read(remoteEditorSnapshotProvider.future); + container + .read(strategyOpQueueProvider.notifier) + .setActiveStrategy('cloud-strategy', accountId: 'account-a'); + container + .read(strategyProvider.notifier) + .setFromState(const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + )); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, repository.batches); + } + + /// Every op sent for [key] in [batches], in order. + List sentFor( + List> batches, + EntitySyncKey key, + ) => + [ + for (final batch in batches) + ...batch.where((op) => EntitySyncKey.forStrategyOp(op) == key), + ]; + + /// The edit to [key] the server refused as deleted waits in attention, + /// with its conflict. + Future refusedAsDeleted( + ProviderContainer container, + List> batches, + EntitySyncKey key, + ) async { + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + await _until(() => queueState().attentionByEntityKey.containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(sentFor(batches, key).single.kind, StrategyOpKind.patch); + expect( + queueState() + .lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having( + (ack) => ack.rejectionReason, 'reason', OpRejectionReason.deleted), + ); + expect(container.read(strategyConflictProvider), hasLength(1)); + } + + /// The edit to [key] the server refused as deleted waits in attention, + /// with its conflict; Keep mine then sends it again. Returns the op it + /// sent, once it has landed. + Future refusedThenKeptMine( + ProviderContainer container, + List> batches, + EntitySyncKey key, + ) async { + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + List sent() => sentFor(batches, key); + await refusedAsDeleted(container, batches, key); + + final opQueue = container.read(strategyOpQueueProvider.notifier); + await opQueue.retryRejected(flushImmediately: false); + await opQueue.flushNow(); + await _until(() => queueState().pending.isEmpty && sent().length == 2); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + return sent().last; + } + + test( + 'Keep mine restores a lineup a teammate deleted while the user ' + 'edited it', () async { + final page = _page('page-1', 0); + server = + _FakeServer(page.publicId, lineups: [_lineup(page.publicId, 'a')]); + final (container, batches) = await openOnRealQueue(page); + LineUpLink? onScreen() => + container.read(lineUpProvider).linkById('link-a'); + expect(onScreen(), isNotNull); + + // A teammate deletes the lineup; this canvas has not read that yet. + server.teammateDelete('link-a'); + expect(server.row('link-a').revision, 2); + + // The user edits its notes. The server refuses the change to the + // tombstone rather than store an edit nobody would see; the user still + // sees their work. + container + .read(lineUpProvider.notifier) + .updateLink(onScreen()!.copyWith(notes: 'mine')); + final restore = await refusedThenKeptMine( + container, batches, _lineupKey(page.publicId, 'a')); + + // Keep mine brought it back over the tombstone, as the user has it. + expect(restore, isA()); + expect((restore as LineupAddOp).expectedLineupRevision, 2); + expect(_onlyLinkIn(restore.payload)['notes'], 'mine'); + final row = server.row('link-a'); + expect(row.deleted, isFalse); + expect(row.revision, 3); + expect(_onlyLinkIn(row.payload)['notes'], 'mine'); + expect(lineUpGraphFromRemoteLineups(server.liveRows).links.single.notes, + 'mine'); + expect(onScreen()?.notes, 'mine'); + }); + + test( + 'Keep mine restores an element a teammate deleted while the user ' + 'moved it', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, + elements: [_textElement(page.publicId, 'text-a', 'hello')]); + final (container, batches) = await openOnRealQueue(page); + PlacedText? onScreen() => container + .read(textProvider) + .where((text) => text.id == 'text-a') + .firstOrNull; + expect(onScreen(), isNotNull); + + // A teammate deletes the text; this canvas has not read that yet. + server.teammateDeleteElement('text-a'); + + // The user moves it. + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'text-a'); + final restore = await refusedThenKeptMine( + container, batches, EntitySyncKey.element(page.publicId, 'text-a')); + + // Keep mine brought it back over the tombstone, where the user put it. + expect(restore, isA()); + expect((restore as ElementAddOp).expectedElementRevision, 2); + final element = server.element('text-a'); + expect(element.deleted, isFalse); + expect(element.revision, 3); + expect(element.payload, restore.payload); + expect(onScreen()?.position, const Offset(300, 300)); + expect(onScreen()?.text, 'hello'); + }); + + /// After [refusedAsDeleted], the user deletes the item on the canvas + /// with [delete]. Both sides agree it is gone, so the refused change is + /// settled, in the queue and in [store], and Keep mine then brings + /// nothing back. + Future deletedAfterRefusal( + ProviderContainer container, + List> batches, + MemoryDurableStrategyOutboxStore store, + EntitySyncKey key, + void Function() delete, + ) async { + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + Iterable stored() => + store.load().records.where((record) => record.entityKey == key); + await refusedAsDeleted(container, batches, key); + expect(stored().single.status, DurableOutboxStatus.attention); + + delete(); + await _until(() => + !queueState().attentionByEntityKey.containsKey(key) && + queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().successorByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(stored(), isEmpty); + // Its refusal is not announced after the fact either. + expect(container.read(strategyConflictProvider), isEmpty); + + // Keep mine has nothing left to send. + final opQueue = container.read(strategyOpQueueProvider.notifier); + await opQueue.retryRejected(flushImmediately: false); + await opQueue.flushNow(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect( + sentFor(batches, key).where((op) => op.kind == StrategyOpKind.add), + isEmpty, + ); + expect(queueState().pending, isEmpty); + expect(stored(), isEmpty); + } + + test( + 'a lineup the user deletes after a teammate deleted it and their ' + 'edit was refused stays deleted through Keep mine', () async { + final page = _page('page-1', 0); + server = + _FakeServer(page.publicId, lineups: [_lineup(page.publicId, 'a')]); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = await openOnRealQueue(page, store: store); + LineUpLink? onScreen() => + container.read(lineUpProvider).linkById('link-a'); + + // A teammate deletes the lineup while the user edits its notes. + server.teammateDelete('link-a'); + container + .read(lineUpProvider.notifier) + .updateLink(onScreen()!.copyWith(notes: 'mine')); + await deletedAfterRefusal( + container, + batches, + store, + _lineupKey(page.publicId, 'a'), + () => container.read(lineUpProvider.notifier).deleteLink('link-a'), + ); + + expect(onScreen(), isNull); + expect(server.row('link-a').deleted, isTrue); + expect(server.row('link-a').revision, 2); + expect(server.liveRows, isEmpty); + }); + + test( + 'an element the user deletes after a teammate deleted it and their ' + 'move was refused stays deleted through Keep mine', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, + elements: [_textElement(page.publicId, 'text-a', 'hello')]); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = await openOnRealQueue(page, store: store); + PlacedText? onScreen() => container + .read(textProvider) + .where((text) => text.id == 'text-a') + .firstOrNull; + + // A teammate deletes the text while the user moves it. + server.teammateDeleteElement('text-a'); + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'text-a'); + await deletedAfterRefusal( + container, + batches, + store, + EntitySyncKey.element(page.publicId, 'text-a'), + () => + container.read(textProvider.notifier).removeTextAsAction('text-a'), + ); + + expect(onScreen(), isNull); + expect(server.element('text-a').deleted, isTrue); + expect(server.element('text-a').revision, 2); + }); + + test( + 'a recovered edit to a lineup group is sent as it was, and opening ' + 'the page writes nothing else', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: [ + fanIn(page.publicId), + _lineup(page.publicId, 'd', sortIndex: 1), + ]); + final group = server.row('link-a'); + final other = server.row('link-d'); + final key = keyOf(page, 'link-a'); + // Before a restart, the user's notes edit to B was saved to the outbox + // but never sent. + final recovered = LineupPatchOp( + opId: 'recovered-notes', + lineupPublicId: 'link-a', + pagePublicId: page.publicId, + payload: _editedGroup( + group, + (data) => _entry(data, 'links', 'link-b')['notes'] = 'recovered', + ).payload, + expectedLineupRevision: group.revision, + ); + final store = MemoryDurableStrategyOutboxStore(); + await store.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + entityKey: key, + pending: PendingOp(op: recovered, clientId: 'client-before-restart'), + status: DurableOutboxStatus.queued, + createdAt: DateTime.utc(2026), + updatedAt: DateTime.utc(2026), + )); + + // Its send waits while the page opens. + final hold = Completer(); + final (container, batches) = + await openOnRealQueue(page, store: store, hold: hold); + for (var i = 0; i < 10; i++) { + await _settle(); + } + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + bool isLineup(StrategyOp op) => + EntitySyncKey.forStrategyOp(op)?.kind == EntitySyncKeyKind.lineup; + + // The page draws both groups, and the recovered edit is the only + // lineup work: nothing replaced it, nothing joined it. + expect(container.read(lineUpProvider).landings.map((l) => l.id), + ['landing', 'landing-d']); + expect( + [ + for (final pending in queueState().pending) + if (isLineup(pending.op)) pending.op.opId, + ], + ['recovered-notes'], + ); + expect( + [for (final record in store.load().records) record.pending.op.opId], + ['recovered-notes'], + ); + + hold.complete(); + await _until(() => queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // Only the recovered edit was ever sent for a lineup, as it was saved. + expect( + [ + for (final batch in batches) + for (final op in batch) + if (isLineup(op)) op.opId, + ], + ['recovered-notes'], + ); + expect(server.received.single.payload, recovered.payload); + expect(server.row('link-a').revision, group.revision + 1); + expect(_linkIn(server.row('link-a').payload, 'link-b')['notes'], + 'recovered'); + expect(server.row('link-d').revision, other.revision); + expect( + canonicalCloudJsonEncode(server.row('link-d').payload), + canonicalCloudJsonEncode(other.payload), + ); + expect( + container.read(lineUpProvider).linkById('link-b')!.notes, + 'recovered', + ); + expect( + container.read(lineUpProvider).landingById('landing')!.ability.position, + newer, + ); + }); + + test( + 'an edit whose answer was lost is answered at the revision it landed ' + 'at, so the edit behind it cannot overwrite a teammate', () async { + final page = _page('page-1', 0); + server = + _FakeServer(page.publicId, lineups: [_lineup(page.publicId, 'a')]); + final hold = Completer(); + final (container, batches) = await openOnRealQueue(page, hold: hold); + final key = _lineupKey(page.publicId, 'a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + LineUpLink onScreen() => + container.read(lineUpProvider).linkById('link-a')!; + + // The user's notes edit reaches the server, which takes it at + // revision 2, but the answer is lost; a teammate renames the lineup + // right after. + repository.loseNextAnswer = () => server.teammateEdit( + 'link-a', _eachLink((link) => link['name'] = 'theirs')); + container + .read(lineUpProvider.notifier) + .updateLink(onScreen().copyWith(notes: 'one')); + await _until(() => queueState().inFlightByEntityKey.containsKey(key)); + // While it is on its way, the user edits again; that waits behind it. + container + .read(lineUpProvider.notifier) + .updateLink(onScreen().copyWith(notes: 'two')); + await _until(() => queueState().successorByEntityKey.containsKey(key)); + hold.complete(); + + // The first edit is sent again and answered as landed at revision 2, + // so the second goes against revision 2: refused, not stored over the + // teammate's name. + await _until(() => queueState().attentionByEntityKey.containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + final sent = sentFor(batches, key); + expect(sent, hasLength(3)); + expect(sent[1].opId, sent[0].opId); + expect(_onlyLinkIn((sent[2] as LineupPatchOp).payload)['notes'], 'two'); + expect((sent[2] as LineupPatchOp).expectedLineupRevision, 2); + final row = server.row('link-a'); + expect(row.revision, 3); + expect(_onlyLinkIn(row.payload)['name'], 'theirs'); + expect(_onlyLinkIn(row.payload)['notes'], 'one'); + expect(container.read(strategyConflictProvider), hasLength(1)); + }); + + /// Every id in [graph]: its lineups, spots, and the agent and ability on + /// each spot. + Set idsIn(LineUpGraph graph) => { + for (final origin in graph.origins) ...[origin.id, origin.agent.id], + for (final landing in graph.landings) ...[ + landing.id, + landing.ability.id, + ], + for (final link in graph.links) link.id, + }; + + /// The lineups [rows] draw, each as `name: notes`. + List lineupsIn(Iterable rows) => [ + for (final link in lineUpGraphFromRemoteLineups(rows).links) + '${link.name}: ${link.notes}', + ]; + + /// The changes [changes] lists, each as `label: description`. + List described(List changes) => [ + for (final change in changes) + '${change.label}: ${change.description}', + ]; + + /// The fan-in group is on the server and on screen; a teammate's notes + /// edit to B lands, then this client's notes edit to A, made against the + /// same revision, is refused and waits. Returns the container, the + /// batches sent and the outbox. + Future< + ( + ProviderContainer, + List>, + MemoryDurableStrategyOutboxStore, + )> refusedBesideTeammate( + RemotePage page, { + MemoryDurableStrategyOutboxStore? store, + List otherPages = const [], + List elements = const [], + }) async { + server = _FakeServer(page.publicId, + lineups: [fanIn(page.publicId)], elements: elements); + store ??= MemoryDurableStrategyOutboxStore(); + final (container, batches) = + await openOnRealQueue(page, store: store, otherPages: otherPages); + final key = keyOf(page, 'link-a'); + + // Both clients start from revision 7; the teammate's save lands first. + server.teammateEdit('link-a', + (data) => _entry(data, 'links', 'link-b')['notes'] = 'theirs'); + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'mine')); + await _until(() => container + .read(strategyOpQueueProvider) + .attentionByEntityKey + .containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + return (container, batches, store); + } + + test( + 'two clients changing different lineups of one group from the same ' + 'revision: the second is refused and waits, nothing overwritten', + () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + final queueState = container.read(strategyOpQueueProvider); + + final sent = sentFor(batches, key); + expect(sent.single, isA()); + expect((sent.single as LineupPatchOp).expectedLineupRevision, 7); + expect( + queueState.lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having((ack) => ack.rejectionReason, 'reason', + OpRejectionReason.revisionMismatch), + ); + + // The server keeps the teammate's version; the user's waits, on + // screen and in the outbox, for them to choose. + expect(server.row('link-a').revision, 8); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + expect(container.read(lineUpProvider).linkById('link-a')!.notes, 'mine'); + expect(queueState.attentionByEntityKey.keys, [key]); + expect(store.load().records.single.status, DurableOutboxStatus.attention); + expect(container.read(strategyConflictProvider), hasLength(1)); + + // The sync popover lists what each side changed. + final conflict = container.read(lineupConflictsProvider)!.single; + expect(conflict.key, key); + expect(described(conflict.yours), ['Heaven: notes edited']); + expect(described(conflict.cloud!), ['Mid: notes edited']); + }); + + test( + 'offline edits to a group a teammate changed meanwhile wait in ' + 'attention on reconnect', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: [fanIn(page.publicId)]); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = await openOnRealQueue(page, store: store); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + const moved = Offset(400, 400); + + container.read(_online.notifier).state = false; + final lineUps = container.read(lineUpProvider.notifier); + lineUps.updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'offline')); + lineUps.updateOriginAgentPosition('origin-a', moved); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(sentFor(batches, key), isEmpty); + expect(queueState().queuedByEntityKey.keys, contains(key)); + + // Meanwhile a teammate renames B. + server.teammateEdit( + 'link-a', (data) => _entry(data, 'links', 'link-b')['name'] = 'B'); + + container.read(_online.notifier).state = true; + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until(() => queueState().attentionByEntityKey.containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // Everything made offline went as one write, against the revision the + // user saw, and was refused rather than stored over the rename. + final sent = sentFor(batches, key).single as LineupPatchOp; + expect(sent.expectedLineupRevision, 7); + expect(_linkIn(sent.payload, 'link-a')['notes'], 'offline'); + expect(_linkIn(sent.payload, 'link-b')['name'], 'Mid'); + expect(server.row('link-a').revision, 8); + expect(lineupsIn(server.liveRows), + ['Heaven: remote lineup', 'B: remote lineup']); + expect( + ((_entry(cloudPayloadData(server.row('link-a').payload), 'origins', + 'origin-a')['agent'] as Map)['position'] as Map)['dx'], + 10, + ); + final onScreen = container.read(lineUpProvider); + expect(onScreen.linkById('link-a')!.notes, 'offline'); + expect(onScreen.originById('origin-a')!.agent.position, moved); + expect(store.load().records.single.status, DurableOutboxStatus.attention); + expect(container.read(strategyConflictProvider), hasLength(1)); + }); + + test('offline edits to a group nobody changed land on reconnect', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: [fanIn(page.publicId)]); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = await openOnRealQueue(page, store: store); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + const moved = Offset(400, 400); + + container.read(_online.notifier).state = false; + final lineUps = container.read(lineUpProvider.notifier); + lineUps.updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'offline')); + lineUps.updateOriginAgentPosition('origin-a', moved); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(sentFor(batches, key), isEmpty); + + container.read(_online.notifier).state = true; + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until(() => + queueState().pending.isEmpty && sentFor(batches, key).isNotEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(sentFor(batches, key).single, isA()); + final row = server.row('link-a'); + expect(row.revision, 8); + expect(lineupsIn([row]), ['Heaven: offline', 'Mid: remote lineup']); + expect( + ((_entry(cloudPayloadData(row.payload), 'origins', 'origin-a')['agent'] + as Map)['position'] as Map)['dx'], + moved.dx, + ); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(store.load().records, isEmpty); + expect(container.read(strategyConflictProvider), isEmpty); + }); + + test( + 'an edit behind one replayed with no revision waits in attention ' + 'instead of going out on a guessed revision', () async { + final page = _page('page-1', 0); + // The server takes the first edit but records no revision for it, as + // events written before it kept one. + server = + _FakeServer(page.publicId, lineups: [_lineup(page.publicId, 'a')]) + ..recordsRevisions = false; + final hold = Completer(); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = + await openOnRealQueue(page, store: store, hold: hold); + final key = _lineupKey(page.publicId, 'a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + LineUpLink onScreen() => + container.read(lineUpProvider).linkById('link-a')!; + + // The first edit lands at revision 2, but its answer is lost; a + // teammate renames the lineup right after. + repository.loseNextAnswer = () => server.teammateEdit( + 'link-a', _eachLink((link) => link['name'] = 'theirs')); + container + .read(lineUpProvider.notifier) + .updateLink(onScreen().copyWith(notes: 'one')); + await _until(() => queueState().inFlightByEntityKey.containsKey(key)); + // The user edits again; that waits behind it. + container + .read(lineUpProvider.notifier) + .updateLink(onScreen().copyWith(notes: 'two')); + await _until(() => queueState().successorByEntityKey.containsKey(key)); + hold.complete(); + + await _until(() => queueState().attentionByEntityKey.containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // The replay is answered as a noop with no revision... + final sent = sentFor(batches, key); + expect(sent, hasLength(2)); + expect(sent[1].opId, sent[0].opId); + final replayed = queueState() + .lastAckBatch + .where((acked) => acked.entityKey == key) + .map((acked) => acked.ack); + expect( + replayed, + everyElement(isA() + .having((ack) => ack.currentRevision, 'revision', isNull)), + ); + // ...so the edit behind it is never sent on a revision the client + // would have had to guess, which would store it over the teammate's + // rename. It waits for the user to choose. + expect( + _onlyLinkIn(queueState().successorByEntityKey[key]!.pending.op.payload + as CloudPayload)['notes'], + 'two', + ); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + final row = server.row('link-a'); + expect(row.revision, 3); + expect(_onlyLinkIn(row.payload)['name'], 'theirs'); + expect(_onlyLinkIn(row.payload)['notes'], 'one'); + expect(onScreen().notes, 'two'); + }); + + /// The fan-in group is on the server and on screen. Offline, the user + /// edits A's notes while a teammate deletes the group; on reconnect the + /// edit is refused as deleted and waits. Returns the container and the + /// batches sent. + Future<(ProviderContainer, List>)> deletedWhileOffline( + RemotePage page, + ) async { + server = _FakeServer(page.publicId, lineups: [fanIn(page.publicId)]); + final (container, batches) = await openOnRealQueue(page); + container.read(_online.notifier).state = false; + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'mine')); + for (var i = 0; i < 10; i++) { + await _settle(); + } + server.teammateDelete('link-a'); + expect(server.row('link-a').revision, 8); + + container.read(_online.notifier).state = true; + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await refusedAsDeleted(container, batches, keyOf(page, 'link-a')); + // The user's version is still on screen. + expect(container.read(lineUpProvider).links.map((link) => link.notes), + ['mine', 'remote lineup']); + return (container, batches); + } + + test( + 'Keep mine brings back a group a teammate deleted while the user ' + 'edited it offline, as the user has it', () async { + final page = _page('page-1', 0); + final (container, batches) = await deletedWhileOffline(page); + + final restore = + await refusedThenKeptMine(container, batches, keyOf(page, 'link-a')); + + expect(restore, isA()); + expect((restore as LineupAddOp).expectedLineupRevision, 8); + final row = server.row('link-a'); + expect(row.deleted, isFalse); + expect(row.revision, 9); + expect(_groupsOf([row]), { + 'link-a': { + 'origins': ['origin-a', 'origin-b'], + 'landings': ['landing'], + 'links': ['link-a', 'link-b'], + }, + }); + expect(lineupsIn([row]), ['Heaven: mine', 'Mid: remote lineup']); + expect(container.read(lineUpProvider).links.map((link) => link.notes), + ['mine', 'remote lineup']); + }); + + test( + 'Keep both after a teammate deleted the group leaves it deleted and ' + 'adds the user\'s version as a copy', () async { + final page = _page('page-1', 0); + final (container, batches) = await deletedWhileOffline(page); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final original = idsIn(container.read(lineUpProvider).graph); + expect(container.read(lineupConflictsProvider), isNotNull); + + final kept = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + expect(kept, KeepBothOutcome.kept); + await _until(() => queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // The group stays deleted, as the teammate left it, and was never + // written again. + expect(server.row('link-a').deleted, isTrue); + expect(server.row('link-a').revision, 8); + expect(sentFor(batches, keyOf(page, 'link-a')), hasLength(1)); + // The user's version is beside it as a group of its own, nothing in it + // the original. + final copy = server.liveRows.single; + final copyGraph = lineUpGraphFromRemoteLineups([copy]); + expect(idsIn(copyGraph).intersection(original), isEmpty); + expect(lineupsIn([copy]), ['Heaven: mine', 'Mid: remote lineup']); + expect(copyGraph.landings, hasLength(1)); + expect(copyGraph.origins, hasLength(2)); + expect(sentFor(batches, keyOf(page, copy.publicId)).single, + isA()); + // On screen, the copy alone; nothing waits. + expect(idsIn(container.read(lineUpProvider).graph), idsIn(copyGraph)); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(container.read(strategyConflictProvider), isEmpty); + expect(container.read(lineupConflictsProvider), isNull); + }); + + test( + 'Keep both keeps the cloud\'s group as the server has it and queues ' + 'the user\'s version as a new group under fresh ids', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final original = idsIn(container.read(lineUpProvider).graph); + + final kept = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + expect(kept, KeepBothOutcome.kept); + await _until(() => queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // The cloud's group is untouched: the teammate's version, revision 8. + final group = server.row('link-a'); + expect(group.revision, 8); + expect(lineupsIn([group]), ['Heaven: remote lineup', 'Mid: theirs']); + expect(sentFor(batches, keyOf(page, 'link-a')), hasLength(1)); + // The user's version went to the server as a new group row. + final copy = + server.liveRows.singleWhere((row) => row.publicId != 'link-a'); + final copyGraph = lineUpGraphFromRemoteLineups([copy]); + expect(idsIn(copyGraph).intersection(original), isEmpty); + expect(lineupsIn([copy]), ['Heaven: mine', 'Mid: remote lineup']); + expect(sentFor(batches, keyOf(page, copy.publicId)).single, + isA()); + // Both are on screen, and nothing waits. + final onScreen = container.read(lineUpProvider); + expect( + [for (final link in onScreen.links) '${link.name}: ${link.notes}'], + unorderedEquals([ + 'Heaven: remote lineup', + 'Mid: theirs', + 'Heaven: mine', + 'Mid: remote lineup', + ])); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(store.load().records, isEmpty); + expect(container.read(strategyConflictProvider), isEmpty); + expect(container.read(lineupConflictsProvider), isNull); + }); + + test( + 'Keep both whose cloud version cannot be loaded changes nothing, so ' + 'pressing it again cannot copy twice', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final before = [ + for (final link in container.read(lineUpProvider).links) link.id, + ]; + liveRead.readFails = true; + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + expect(outcome, KeepBothOutcome.unchanged); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // No copy, on screen or on the server... + expect( + [for (final link in container.read(lineUpProvider).links) link.id], + before, + ); + expect(server.liveRows, hasLength(1)); + // ...and the original work still waits for the user to choose. + expect(queueState().attentionByEntityKey.keys, [key]); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect(sentFor(batches, key), hasLength(1)); + expect(container.read(strategyConflictProvider), hasLength(1)); + }); + + /// The lineups on screen, each as `name: notes`. + List onScreenOf(ProviderContainer container) => [ + for (final link in container.read(lineUpProvider).links) + '${link.name}: ${link.notes}', + ]; + + test( + 'Keep both whose copy cannot be saved discards nothing: the copy and ' + 'the waiting work both stay', () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, batches, _) = + await refusedBesideTeammate(page, store: store); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final waiting = queueState().attentionByEntityKey[key]!.pending.op.opId; + // The copy's outbox write fails; the waiting work's record is kept. + store.failPut = (record) => record.entityKey != key; + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(outcome, KeepBothOutcome.copiesOnly); + // The original still waits, in memory and on disk, as it was. + expect(queueState().attentionByEntityKey[key]!.pending.op.opId, waiting); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect(sentFor(batches, key), hasLength(1)); + // The copy is still on screen beside the user's version; the cloud's + // version was not taken. + expect( + onScreenOf(container), + [ + 'Heaven: mine', + 'Mid: remote lineup', + 'Heaven: mine', + 'Mid: remote lineup' + ], + ); + // The server has only the teammate's version; nothing was discarded. + expect(server.liveRows.map((row) => row.publicId), ['link-a']); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + expect(queueState().hasDurabilityFailure, isTrue); + }); + + test( + 'an edit to the conflicting group while Keep both loads the cloud ' + 'version stops it: no copy, and the newer edit still waits', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final refreshes = liveRead.refreshCount; + final gate = liveRead.refreshGate = Completer(); + + final keepBoth = container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + await _until(() => liveRead.refreshCount > refreshes); + // While the cloud's version is on its way, the user edits the group. + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-b')! + .copyWith(notes: 'newer')); + gate.complete(); + + expect(await keepBoth, KeepBothOutcome.unchanged); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // No copy was made of the version the edit replaced... + expect(onScreenOf(container), ['Heaven: mine', 'Mid: newer']); + expect(server.liveRows.map((row) => row.publicId), ['link-a']); + // ...and the newer edit waits behind the refused one, for the user. + expect(queueState().attentionByEntityKey.keys, [key]); + final newer = queueState().successorByEntityKey[key]!.pending.op; + expect( + _linkIn(newer.payload as CloudPayload, 'link-b')['notes'], 'newer'); + expect(_linkIn(newer.payload as CloudPayload, 'link-a')['notes'], 'mine'); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + }); + + test( + 'switching page while Keep both loads the cloud version stops it: ' + 'no copy on either page', () async { + final page = _page('page-1', 0); + final other = _page('page-2', 1); + final (container, _, _) = + await refusedBesideTeammate(page, otherPages: [other]); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + bool copyQueued() => queueState().pending.any((pending) => + pending.op is LineupAddOp || + EntitySyncKey.forStrategyOp(pending.op)?.pageId == other.publicId); + final refreshes = liveRead.refreshCount; + final gate = liveRead.refreshGate = Completer(); + + final keepBoth = session.keepBothForRejected(); + await _until(() => liveRead.refreshCount > refreshes); + // While the cloud's version is on its way, the user opens page 2. + await session.setActivePage(other.publicId); + readPage(other.publicId); + gate.complete(); + + expect(await keepBoth, KeepBothOutcome.unchanged); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(container.read(strategyPageSessionProvider).activePageId, + other.publicId); + expect(container.read(lineUpProvider).links, isEmpty); + expect(copyQueued(), isFalse); + + // Back on page 1: the user's version, and no copy of it. + await session.setActivePage(page.publicId); + readPage(page.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(onScreenOf(container), ['Heaven: mine', 'Mid: remote lineup']); + expect(copyQueued(), isFalse); + expect(server.liveRows.map((row) => row.publicId), ['link-a']); + expect(queueState().attentionByEntityKey.keys, [key]); + }); + + test( + 'once the page is drawn again, the conflict lists how the user\'s ' + 'version differs from the cloud\'s, not who changed what', () async { + final page = _page('page-1', 0); + final other = _page('page-2', 1); + final (container, _, _) = + await refusedBesideTeammate(page, otherPages: [other]); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + expect(container.read(lineupConflictsProvider)!.single.cloud, isNotNull); + + // The user leaves the page and comes back while the conflict waits: + // the page is drawn from the cloud's version, revision 8. + await session.setActivePage(other.publicId); + readPage(other.publicId); + await _settle(); + await session.setActivePage(page.publicId); + readPage(page.publicId); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect( + container + .read(activePageLiveSyncProvider.notifier) + .hydratedBase(key) + ?.revision, + 8, + ); + + final conflict = container.read(lineupConflictsProvider)!.single; + expect(conflict.key, key); + // Against revision 8, the teammate's notes on Mid would read as the + // user's, so the sides are not told apart: only the difference. + expect(conflict.cloud, isNull); + expect(described(conflict.yours), + ['Heaven: notes edited', 'Mid: notes edited']); + expect( + described(conflict.yours), + described(lineupChanges( + from: lineUpGraphFromRemoteLineups([server.row('link-a')]), + to: lineUpGraphFromCloudRows([ + CloudLineupRow( + publicId: 'link-a', + payload: container + .read(strategyOpQueueProvider) + .attentionByEntityKey[key]! + .pending + .op + .payload as CloudPayload, + ), + ]).graph, + )), + ); + }); + + test( + 'Keep both pressed again after it copied but could not resolve ' + 'finishes without a second copy', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + List copies() => [ + for (final row in server.liveRows) + if (row.publicId != 'link-a') row, + ]; + + // The first read, before the copy, works; the cloud's version then + // stops loading. + final first = liveRead.refreshCount + 1; + liveRead.readFailsWhen = (count) => count > first; + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + // The copy goes out; the waiting work stays. + await _until(() => + queueState().queuedByEntityKey.isEmpty && + queueState().inFlightByEntityKey.isEmpty && + server.liveRows.length == 2); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(copies(), hasLength(1)); + expect(queueState().attentionByEntityKey.keys, [key]); + + // Pressed again with the cloud reachable, it only resolves. + liveRead.readFailsWhen = null; + expect(await session.keepBothForRejected(), KeepBothOutcome.kept); + await _until(() => queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + final copy = copies().single; + expect(lineupsIn([copy]), ['Heaven: mine', 'Mid: remote lineup']); + expect(sentFor(batches, keyOf(page, copy.publicId)), hasLength(1)); + expect(lineupsIn([server.row('link-a')]), + ['Heaven: remote lineup', 'Mid: theirs']); + expect( + onScreenOf(container), + unorderedEquals([ + 'Heaven: remote lineup', + 'Mid: theirs', + 'Heaven: mine', + 'Mid: remote lineup', + ]), + ); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(store.load().records, isEmpty); + }); + + test( + 'an edit to the conflicting group during Keep both\'s second cloud ' + 'read, after the copy, is neither discarded nor replaced', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + List copies() => [ + for (final row in server.liveRows) + if (row.publicId != 'link-a') row, + ]; + // The copy's send waits, so no answer brings a read of its own; the + // read held is the one that loads the cloud's version to take it. + final send = repository.hold = Completer(); + final gate = liveRead.refreshGate = Completer(); + liveRead.gateWhen = + () => container.read(lineUpProvider).links.length == 4; + + final keepBoth = container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + await _until(() => liveRead.refreshGate == null); + expect(onScreenOf(container), hasLength(4), reason: 'the copy is made'); + // While the cloud's version is on its way, the user edits the group. + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-b')! + .copyWith(notes: 'newer')); + // From here the canvas never shows the cloud's version in place of + // the edit, even for a moment. + var replaced = false; + container.listen(lineUpProvider, (_, next) { + if (next.linkById('link-b')?.notes != 'newer') replaced = true; + }); + gate.complete(); + + expect(await keepBoth, KeepBothOutcome.copiesOnly); + expect(replaced, isFalse); + send.complete(); + await _until(() => + queueState().queuedByEntityKey.isEmpty && + queueState().inFlightByEntityKey.isEmpty && + copies().length == 1); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + // The newer edit is on screen and waits behind the refused one... + expect(container.read(lineUpProvider).linkById('link-b')!.notes, 'newer'); + expect(queueState().attentionByEntityKey.keys, [key]); + final newer = queueState().successorByEntityKey[key]!.pending.op; + expect( + _linkIn(newer.payload as CloudPayload, 'link-b')['notes'], 'newer'); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + // ...the cloud's group is untouched, and there is one copy. + expect(lineupsIn([server.row('link-a')]), + ['Heaven: remote lineup', 'Mid: theirs']); + expect(lineupsIn(copies()), ['Heaven: mine', 'Mid: remote lineup']); + expect(onScreenOf(container), [ + 'Heaven: mine', + 'Mid: newer', + 'Heaven: mine', + 'Mid: remote lineup', + ]); + }); + + test( + 'Keep both while the outbox still cannot save is not offered and ' + 'changes nothing', () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, batches, _) = + await refusedBesideTeammate(page, store: store); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final waiting = queueState().attentionByEntityKey[key]!.pending.op.opId; + // Only the copy's write fails, so nothing but lineup groups waits. + store.failPut = (record) => + record.entityKey.kind == EntitySyncKeyKind.lineup && + record.entityKey != key; + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(queueState().hasDurabilityFailure, isTrue); + final onScreen = onScreenOf(container); + expect(onScreen, hasLength(4)); + + // While writes still fail, the popover does not offer it... + expect(container.read(lineupConflictsProvider), isNull); + // ...and pressed anyway, it changes nothing. + expect( + queueState().attentionByEntityKey.keys, + everyElement(predicate( + (key) => key.kind == EntitySyncKeyKind.lineup))); + expect(await session.keepBothForRejected(), KeepBothOutcome.unchanged); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(onScreenOf(container), onScreen); + expect( + queueState().pending.where((pending) => pending.op is LineupAddOp), + hasLength(1), + reason: 'only the first copy waits to be saved', + ); + expect(queueState().attentionByEntityKey[key]!.pending.op.opId, waiting); + expect(sentFor(batches, key), hasLength(1)); + expect(server.liveRows.map((row) => row.publicId), ['link-a']); + expect(container.read(lineupConflictsProvider), isNull); + }); + + test( + 'an edit to the conflicting group while Use cloud loads the cloud ' + 'version is neither discarded nor replaced', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final refreshes = liveRead.refreshCount; + final gate = liveRead.refreshGate = Completer(); + + final useCloud = container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _until(() => liveRead.refreshCount > refreshes); + // While the cloud's version is on its way, the user edits the group. + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-b')! + .copyWith(notes: 'newer')); + var replaced = false; + container.listen(lineUpProvider, (_, next) { + if (next.linkById('link-b')?.notes != 'newer') replaced = true; + }); + gate.complete(); + + expect(await useCloud, isFalse); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(replaced, isFalse); + expect(onScreenOf(container), ['Heaven: mine', 'Mid: newer']); + // The refused work and the newer edit behind it both still wait. + expect(queueState().attentionByEntityKey.keys, [key]); + final newer = queueState().successorByEntityKey[key]!.pending.op; + expect( + _linkIn(newer.payload as CloudPayload, 'link-b')['notes'], 'newer'); + final record = + store.load().records.singleWhere((r) => r.entityKey == key); + expect(record.status, DurableOutboxStatus.attention); + expect(record.successorPending!.op.opId, newer.opId); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + }); + + test( + 'Keep both whose copy is not saved because edit access went away ' + 'discards nothing', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final waiting = queueState().attentionByEntityKey[key]!.pending.op.opId; + // The cloud read Keep both makes finds the user is a viewer now, so + // the page's edits, the copy included, are no longer saved. + final now = liveRead.initialSnapshot; + liveRead.initialSnapshot = _editorSnapshot( + pages: now.pages, + activePage: now.activePage!, + role: 'viewer', + ); + + final outcome = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(outcome, KeepBothOutcome.copiesOnly); + expect(queueState().hasDurabilityFailure, isFalse); + expect( + queueState().pending.where((pending) => pending.op is LineupAddOp), + isEmpty, + reason: 'the copy was never queued', + ); + // The waiting work is untouched, in memory and on disk. + expect(queueState().attentionByEntityKey[key]!.pending.op.opId, waiting); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect(sentFor(batches, key), hasLength(1)); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + expect(onScreenOf(container), contains('Heaven: mine')); + }); + + test( + 'a canvas edit after Use cloud\'s last save, while it loads the page, ' + 'stops the redraw', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + // The cloud read comes back before the page itself, so loading the + // page waits for it: the one wait between the last save and the + // redraw. The user's edit lands there. + final now = liveRead.initialSnapshot; + liveRead.initialSnapshot = + RemoteEditorSnapshot(shell: now.shell, activePage: null); + var replaced = false; + var edited = false; + liveRead.onSetActivePage = (pageId) { + if (edited || pageId != page.publicId) return; + edited = true; + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-b')! + .copyWith(notes: 'later')); + container.listen(lineUpProvider, (_, next) { + if (next.linkById('link-b')?.notes != 'later') replaced = true; + }); + }; + + final resolved = await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(edited, isTrue, reason: 'the edit landed in the window'); + expect(resolved, isFalse); + expect(replaced, isFalse); + expect(onScreenOf(container), ['Heaven: mine', 'Mid: later']); + expect(queueState().attentionByEntityKey.keys, [key]); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + }); + + test( + 'an edit made while Use cloud\'s last save is being written stops the ' + 'redraw', () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, _, _) = + await refusedBesideTeammate(page, store: store, elements: [ + _textElement(page.publicId, 'text-a', 'a'), + _textElement(page.publicId, 'text-b', 'b', sortIndex: 1), + ]); + String textOf(String id) => + container.read(textProvider).singleWhere((t) => t.id == id).text; + final refreshes = liveRead.refreshCount; + final gate = liveRead.refreshGate = Completer(); + + final useCloud = container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _until(() => liveRead.refreshCount > refreshes); + // An edit made while the cloud's version loads gives the last save + // something to write... + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'text-a'); + // ...and while that write is on its way, the user types elsewhere. + var typed = false; + var replaced = false; + store.onPut = (record) { + if (typed || record.entityKey.entityId != 'text-a') return; + typed = true; + container.read(textProvider.notifier).commitText('text-b', 'typed'); + container.listen(textProvider, (_, next) { + if (next.singleWhere((t) => t.id == 'text-b').text != 'typed') { + replaced = true; + } + }); + }; + gate.complete(); + + final resolved = await useCloud; + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(typed, isTrue, reason: 'the edit landed during the write'); + expect(replaced, isFalse, reason: 'the redraw put back the old text'); + expect(textOf('text-b'), 'typed'); + expect(resolved, isFalse); + }); + + test( + 'Keep both pressed again while the copy still cannot be saved ' + 'discards nothing and redraws nothing', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final waiting = queueState().attentionByEntityKey[key]!.pending.op.opId; + // Keep both's reads find the user is a viewer now, so nothing they + // edit is saved, the copy included. + final now = liveRead.initialSnapshot; + liveRead.initialSnapshot = _editorSnapshot( + pages: now.pages, + activePage: now.activePage!, + role: 'viewer', + ); + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + for (var i = 0; i < 10; i++) { + await _settle(); + } + final withCopy = onScreenOf(container); + expect(withCopy, hasLength(4)); + + // Pressed again, still a viewer: the copy it remembers is still not + // saved, so the original is not discarded on its behalf. + var redrawn = false; + container.listen(lineUpProvider, (_, __) => redrawn = true); + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(redrawn, isFalse); + expect(onScreenOf(container), withCopy); + expect(queueState().attentionByEntityKey[key]!.pending.op.opId, waiting); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect( + queueState().pending.where((pending) => pending.op is LineupAddOp), + isEmpty, + ); + expect(sentFor(batches, key), hasLength(1)); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + }); + + test( + 'an edit whose save fails while Use cloud loads the cloud version ' + 'stops it: the edit stays on screen and nothing is discarded', + () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, batches, _) = + await refusedBesideTeammate(page, store: store); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + final waiting = queueState().attentionByEntityKey[key]!.pending.op.opId; + final refreshes = liveRead.refreshCount; + final gate = liveRead.refreshGate = Completer(); + + final useCloud = container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _until(() => liveRead.refreshCount > refreshes); + // While the cloud's version is on its way, the user edits the group, + // and that edit cannot be written to the outbox. + store.failPut = (record) => record.entityKey == key; + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-b')! + .copyWith(notes: 'newer')); + var replaced = false; + container.listen(lineUpProvider, (_, next) { + if (next.linkById('link-b')?.notes != 'newer') replaced = true; + }); + gate.complete(); + + expect(await useCloud, isFalse); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(queueState().hasDurabilityFailure, isTrue); + expect(replaced, isFalse); + expect(onScreenOf(container), ['Heaven: mine', 'Mid: newer']); + // The refused work is not discarded, in memory or on disk. + expect(queueState().attentionByEntityKey[key]!.pending.op.opId, waiting); + final record = + store.load().records.singleWhere((r) => r.entityKey == key); + expect(record.status, DurableOutboxStatus.attention); + expect(record.pending.op.opId, waiting); + expect(sentFor(batches, key), hasLength(1)); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + }); + + /// The server's refusal of lineup group [key] for overlapping another. + OpAck? Function(StrategyOp op) refuseAsOverlapping(EntitySyncKey key) => + (op) => EntitySyncKey.forStrategyOp(op) == key + ? FailedOpAck( + opId: op.opId, + code: 'INVALID_LINEUP_PAYLOAD_DATA', + rawCode: 'INVALID_LINEUP_PAYLOAD_DATA', + message: lineupOverlapMessage, + ) + : null; + + test( + 'a lineup group refused for overlapping another, alone, reaches the ' + 'sync button as exactly that reason', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: [fanIn(page.publicId)]); + final (container, _) = await openOnRealQueue(page); + final key = keyOf(page, 'link-a'); + repository.refuse = refuseAsOverlapping(key); + + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'mine')); + await _until(() => container + .read(strategyOpQueueProvider) + .attentionByEntityKey + .containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(container.read(strategyOpQueueProvider).lastError, + lineupOverlapMessage); + expect(container.read(strategySaveStateProvider).cloudSyncError, + lineupOverlapMessage); + }); + + test( + 'an overlap refusal beside a teammate deletion Keep mine can restore ' + 'is not reported as the only reason', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, lineups: [ + fanIn(page.publicId), + _lineup(page.publicId, 'd', sortIndex: 1), + ]); + final (container, _) = await openOnRealQueue(page); + final overlapping = keyOf(page, 'link-a'); + final deleted = keyOf(page, 'link-d'); + repository.refuse = refuseAsOverlapping(overlapping); + // A teammate deletes lineup d; the user edits both groups. + server.teammateDelete('link-d'); + final lineUps = container.read(lineUpProvider.notifier); + lineUps.updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'mine')); + lineUps.updateLink(container + .read(lineUpProvider) + .linkById('link-d')! + .copyWith(notes: 'mine')); + await _until(() => + container.read(strategyOpQueueProvider).attentionByEntityKey.length == + 2); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey.keys, + unorderedEquals([overlapping, deleted]), + ); + + // Keep mine can bring lineup d back, so the overlap is not the only + // reason, and the sync button must not read as if it were. + expect(container.read(strategySaveStateProvider).cloudSyncError, + isNot(lineupOverlapMessage)); + }); + + test( + 'a move made while Use cloud discards, when the discard partly ' + 'fails, is not drawn over by the redraw that follows', () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, _, _) = await refusedBesideTeammate(page, + store: store, elements: [_textElement(page.publicId, 'text-a', 'a')]); + final key = keyOf(page, 'link-a'); + Offset textAt() => container + .read(textProvider) + .singleWhere((text) => text.id == 'text-a') + .position; + const moved = Offset(300, 300); + // Removing the refused work from the outbox fails; while it is on its + // way, the user moves the text. + var movedDuringDiscard = false; + store.onRemove = (storageKey) { + if (movedDuringDiscard) return; + movedDuringDiscard = true; + container.read(textProvider.notifier).updatePosition(moved, 'text-a'); + }; + store.failRemove = (storageKey) => true; + var redrawnOver = false; + + final useCloud = container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(); + await _until(() => movedDuringDiscard); + container.listen(textProvider, (_, next) { + if (next.singleWhere((text) => text.id == 'text-a').position != moved) { + redrawnOver = true; + } + }); + + expect(await useCloud, isFalse); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(redrawnOver, isFalse); + expect(textAt(), moved); + // The refused work still waits: its record could not be removed. + expect(container.read(strategyOpQueueProvider).attentionByEntityKey.keys, + [key]); + }); + + test( + 'Use cloud after storage recovers from a failed discard does not ' + 'claim success while the work goes out again, and the next press ' + 'resolves it', () async { + final page = _page('page-1', 0); + final store = _FailingOutboxStore(); + final (container, batches, _) = + await refusedBesideTeammate(page, store: store); + final key = keyOf(page, 'link-a'); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + + // The first press cannot remove the refused work from the outbox. + store.failRemove = (storageKey) => true; + expect(await session.useCloudVersionsForRejected(), isFalse); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(queueState().attentionByEntityKey.keys, [key]); + expect(queueState().lastError, contains('could not be removed')); + expect(onScreenOf(container), ['Heaven: mine', 'Mid: remote lineup']); + + // Storage recovers. The record the removal left uncertain goes back + // to the send queue on the next save, so this press finds nothing to + // resolve; it says so rather than claim the cloud's version is shown. + store.failRemove = null; + expect(await session.useCloudVersionsForRejected(), isFalse); + expect(onScreenOf(container), ['Heaven: mine', 'Mid: remote lineup']); + + // The refused work goes out once more and is refused again: the + // teammate's version stands. + await _until(() => + sentFor(batches, key).length == 2 && + queueState().attentionByEntityKey.containsKey(key)); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(sentFor(batches, key)[1].opId, sentFor(batches, key)[0].opId); + expect( + queueState() + .lastAckBatch + .singleWhere((acked) => acked.entityKey == key) + .ack, + isA().having((ack) => ack.rejectionReason, 'reason', + OpRejectionReason.revisionMismatch), + ); + expect(server.row('link-a').revision, 8); + expect( + lineupsIn(server.liveRows), ['Heaven: remote lineup', 'Mid: theirs']); + + // A further press takes the cloud's version. (The strategy's own + // details it saves on the way land too.) + expect(await session.useCloudVersionsForRejected(), isTrue); + await _until(() => queueState().pending.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + expect(onScreenOf(container), ['Heaven: remote lineup', 'Mid: theirs']); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(queueState().pending, isEmpty); + expect(store.load().records, isEmpty); + expect(sentFor(batches, key), hasLength(2)); + }); + + test( + 'Keep both after the user deleted the copy an earlier press made ' + 'copies again and resolves, one copy in all', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + List copies() => [ + for (final row in server.liveRows) + if (row.publicId != 'link-a') row, + ]; + List copyLinkIds() => [ + for (final link in container.read(lineUpProvider).links) + if (link.id != 'link-a' && link.id != 'link-b') link.id, + ]; + Future landed() async { + await _until(() => + queueState().queuedByEntityKey.isEmpty && + queueState().inFlightByEntityKey.isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + } + + // The first press copies, then cannot load the cloud's version. + final first = liveRead.refreshCount + 1; + liveRead.readFailsWhen = (count) => count > first; + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + await landed(); + expect(copies(), hasLength(1)); + final firstCopy = copyLinkIds(); + expect(firstCopy, hasLength(2)); + + // The cloud is reachable again; the user deletes the copy, and the + // deletion lands. + liveRead.readFailsWhen = null; + showServer(); + await _settle(); + final lineUps = container.read(lineUpProvider.notifier); + for (final id in firstCopy) { + lineUps.deleteLink(id); + } + await _settle(); + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until(() => copies().isEmpty); + await landed(); + expect(copyLinkIds(), isEmpty); + + // Pressed again with the cloud reachable: a fresh copy, and resolved. + expect(await session.keepBothForRejected(), KeepBothOutcome.kept); + await landed(); + + final copy = copies().single; + expect(lineupsIn([copy]), ['Heaven: mine', 'Mid: remote lineup']); + expect(copyLinkIds(), hasLength(2)); + expect(copyLinkIds().toSet().intersection(firstCopy.toSet()), isEmpty); + expect( + onScreenOf(container), + unorderedEquals([ + 'Heaven: remote lineup', + 'Mid: theirs', + 'Heaven: mine', + 'Mid: remote lineup', + ]), + ); + expect(queueState().attentionByEntityKey, isEmpty); + expect(store.load().records, isEmpty); + }); + + test( + 'Keep both after the user deleted one lineup of the copy keeps the ' + 'rest of it and resolves without a second copy', () async { + final page = _page('page-1', 0); + final (container, _, store) = await refusedBesideTeammate(page); + final session = container.read(strategyPageSessionProvider.notifier); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + List copies() => [ + for (final row in server.liveRows) + if (row.publicId != 'link-a') row, + ]; + List copiedLinks() => [ + for (final link in container.read(lineUpProvider).links) + if (link.id != 'link-a' && link.id != 'link-b') link, + ]; + Future landed() async { + await _until(() => queueState() + .pending + .where((work) => !queueState() + .attentionByEntityKey + .values + .any((waiting) => waiting.pending.op.opId == work.op.opId)) + .isEmpty); + for (var i = 0; i < 10; i++) { + await _settle(); + } + } + + // The first press copies both lineups, then cannot load the cloud's + // version. + final first = liveRead.refreshCount + 1; + liveRead.readFailsWhen = (count) => count > first; + expect(await session.keepBothForRejected(), KeepBothOutcome.copiesOnly); + await landed(); + expect(lineupsIn(copies()), ['Heaven: mine', 'Mid: remote lineup']); + + // The cloud is reachable again; the user deletes one copied lineup. + liveRead.readFailsWhen = null; + showServer(); + await _settle(); + final heaven = copiedLinks().singleWhere((link) => link.name == 'Heaven'); + final mid = copiedLinks().singleWhere((link) => link.name == 'Mid'); + container.read(lineUpProvider.notifier).deleteLink(heaven.id); + await _settle(); + await container.read(strategyOpQueueProvider.notifier).flushNow(); + await _until(() => lineupsIn(copies()).length == 1); + await landed(); + + // Pressed again: the rest of the copy stands, nothing is copied again. + expect(await session.keepBothForRejected(), KeepBothOutcome.kept); + await landed(); + + expect(lineupsIn(copies()), ['Mid: remote lineup']); + expect(copiedLinks().map((link) => link.id), [mid.id]); + expect( + onScreenOf(container), + unorderedEquals( + ['Heaven: remote lineup', 'Mid: theirs', 'Mid: remote lineup']), + ); + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect(store.load().records, isEmpty); + }); + + test( + 'Use cloud pressed before a deleted-on-both-sides refusal settles ' + 'counts its settling as resolved', () async { + final page = _page('page-1', 0); + server = + _FakeServer(page.publicId, lineups: [_lineup(page.publicId, 'a')]); + final store = MemoryDurableStrategyOutboxStore(); + final (container, batches) = await openOnRealQueue(page, store: store); + final key = _lineupKey(page.publicId, 'a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + + // A teammate deletes the lineup while the user edits it: refused as + // deleted. + server.teammateDelete('link-a'); + container.read(lineUpProvider.notifier).updateLink(container + .read(lineUpProvider) + .linkById('link-a')! + .copyWith(notes: 'mine')); + await refusedAsDeleted(container, batches, key); + + // The user deletes it too and, before that is saved, presses Use + // cloud: its save settles the refusal, both sides having it gone. + container.read(lineUpProvider.notifier).deleteLink('link-a'); + expect(queueState().attentionByEntityKey.keys, [key]); + expect( + await container + .read(strategyPageSessionProvider.notifier) + .useCloudVersionsForRejected(), + isTrue, + ); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + expect(queueState().attentionByEntityKey, isEmpty); + expect(queueState().needsAttention, isFalse); + expect( + [for (final record in store.load().records) record.entityKey], + isNot(contains(key)), + ); + expect(container.read(lineUpProvider).links, isEmpty); + expect(server.row('link-a').deleted, isTrue); + expect(sentFor(batches, key), hasLength(1)); + }); + + test( + 'after Keep both, exporting the strategy to .ica and importing it ' + 'brings back both versions', () async { + final page = _page('page-1', 0); + final (container, _, _) = await refusedBesideTeammate(page); + final kept = await container + .read(strategyPageSessionProvider.notifier) + .keepBothForRejected(); + expect(kept, KeepBothOutcome.kept); + await _until(() => + container.read(strategyOpQueueProvider).pending.isEmpty && + server.liveRows.length == 2); + + // Exported from the server's copy, as the cloud library exports. + final now = DateTime.utc(2026); + final exported = + StrategyImportExportService.strategyDataFromRemoteSnapshot( + RemoteFullStrategySnapshot( + header: RemoteStrategyHeader( + publicId: 'cloud-strategy', + name: 'Cloud Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + ), + pages: [ + RemoteFullPage( + page: page, + content: RemotePageContent( + settings: settingsFor(1), + revision: 1, + createdAt: now, + updatedAt: now, + ), + ), + ], + elementsByPage: const {}, + lineupsByPage: {page.publicId: server.rows}, + assetsById: const {}, + ), + ); + + final box = await _openStrategyBox(); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + final files = await Directory.systemTemp.createTemp('icarus-keep-both-'); + addTearDown(() => files.delete(recursive: true)); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const pathProvider = MethodChannel('plugins.flutter.io/path_provider'); + messenger.setMockMethodCallHandler( + pathProvider, (call) async => files.path); + addTearDown(() => messenger.setMockMethodCallHandler(pathProvider, null)); + + final service = StrategyImportExportService(container); + final archive = await service.zipStrategyData( + strategy: exported, + outputFilePath: '${files.path}${Platform.pathSeparator}Keep both.ica', + ); + await service.loadFromFilePath(archive); + + final imported = box.values.single; + final graph = imported.pages.single.lineUpGraph; + List described(LineUpGraph graph) => [ + for (final link in graph.links) '${link.name}: ${link.notes}', + ]; + expect( + described(graph), + described(exported.pages.single.lineUpGraph), + ); + expect(described(graph), [ + 'Heaven: remote lineup', + 'Mid: theirs', + 'Heaven: mine', + 'Mid: remote lineup', + ]); + expect(graph.origins, hasLength(4)); + expect(graph.landings, hasLength(2)); + expect( + jsonEncode(graph.toJson()), + jsonEncode(exported.pages.single.lineUpGraph.toJson()), + ); + }); + + test( + 'refused work the user then deletes on canvas stays in attention ' + 'unless the refusal said the item was deleted', () async { + final page = _page('page-1', 0); + final (container, batches, store) = await refusedBesideTeammate(page); + final key = keyOf(page, 'link-a'); + StrategyOpQueueState queueState() => + container.read(strategyOpQueueProvider); + + // The teammate then deletes the group, and the user deletes both its + // lineups on the canvas: both sides have it gone, but the refusal was + // a revision mismatch, not a deletion. + server.teammateDelete('link-a'); + showServer(); + final lineUps = container.read(lineUpProvider.notifier); + lineUps.deleteLink('link-a'); + lineUps.deleteLink('link-b'); + expect(container.read(lineUpProvider).links, isEmpty); + for (var i = 0; i < 20; i++) { + await _settle(); + } + + // Nothing about the refusal says the server deleted what it edits, so + // the work is not dropped in silence: it still waits. + expect(queueState().attentionByEntityKey.keys, [key]); + expect(queueState().needsAttention, isTrue); + expect( + store.load().records.singleWhere((r) => r.entityKey == key).status, + DurableOutboxStatus.attention, + ); + expect(container.read(strategyConflictProvider), hasLength(1)); + expect(sentFor(batches, key), hasLength(1)); + }); + }); + + group('lineup history across rehydration', () { + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> + openWithLineup() async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + lineups: [_lineup(page.publicId, 'lineup-a')], + ), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + expect(container.read(lineUpProvider).origins.single.id, 'lineup-a'); + return (container, remote, page); + } + + /// The server accepted the local edit and the page rehydrates from the + /// new snapshot, keeping history (a cloud ack). + Future ackAndRehydrate( + ProviderContainer container, + _FakeRemoteEditorNotifier remote, + RemotePage page, + List lineups, + ) async { + container.read(strategySaveStateProvider.notifier).markPersisted(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, contentRevision: 2, lineups: lineups), + )); + await _settle(); + } + + test('undoing a lineup delete still restores it after an ack', () async { + final (container, remote, page) = await openWithLineup(); + + container.read(lineUpProvider.notifier).deleteOrigin('lineup-a'); + expect(container.read(lineUpProvider).origins, isEmpty); + await ackAndRehydrate(container, remote, page, const []); + expect(container.read(lineUpProvider).origins, isEmpty); + + container.read(actionProvider.notifier).undoAction(); + + final lineUps = container.read(lineUpProvider); + expect(lineUps.origins.single.id, 'lineup-a'); + expect(lineUps.links.single.id, 'link-lineup-a'); + expect(lineUps.landings, hasLength(1)); + + container.read(actionProvider.notifier).redoAction(); + expect(container.read(lineUpProvider).links, isEmpty); + await _settle(); + }); + + test('undoing a marker move keeps a teammate lineup that arrived since', + () async { + final (container, remote, page) = await openWithLineup(); + const moved = Offset(200, 220); + + container + .read(lineUpProvider.notifier) + .updateOriginAgentPosition('lineup-a', moved); + // The move lands and a teammate's lineup B arrives with it. + await ackAndRehydrate(container, remote, page, [ + _lineup(page.publicId, 'lineup-a', agentPosition: moved, revision: 2), + _lineup(page.publicId, 'lineup-b', sortIndex: 1), + ]); + expect( + container.read(lineUpProvider).origins.map((origin) => origin.id), + ['lineup-a', 'lineup-b'], + ); + + container.read(actionProvider.notifier).undoAction(); + + final lineUps = container.read(lineUpProvider); + expect( + lineUps.origins.map((origin) => origin.id), + ['lineup-a', 'lineup-b'], + ); + expect( + lineUps.originById('lineup-a')!.agent.position, + const Offset(10, 20), + ); + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + expect(desired, isNotNull); + expect(desired!.values.whereType(), isEmpty); + expect( + desired[_lineupKey(page.publicId, 'lineup-a')], + isA(), + ); + expect( + desired.keys + .where((key) => key.entityId?.endsWith('lineup-b') ?? false), + isEmpty, + ); + await _settle(); + }); + }); + + test('unhydrated canvas cannot author a remote lineup deletion', () async { + final page = _page('page-1', 0); + final container = await _syncContainer( + remote: _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + lineups: [_lineup(page.publicId, 'lineup-1')], + ), + )), + queue: _FakeStrategyOpQueueNotifier(), + ); + container.read(activePageLiveSyncProvider.notifier).setContext( + strategyPublicId: 'cloud-strategy', + activePageId: page.publicId, + ); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + expect(desired, isNull); + }); + + test('new remote lineup is not inferred as a local deletion', () async { + final page = _page('page-1', 0); + final before = _editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote'), + ); + final remote = _FakeRemoteEditorNotifier(before); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(strategySaveStateProvider.notifier).markDirty(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + text: 'remote', + contentRevision: 2, + lineups: [_lineup(page.publicId, 'lineup-1')], + ), + )); + + final desired = + container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ); + + expect(desired, isNotNull); + expect( + desired!.keys.where((key) => key.kind == EntitySyncKeyKind.lineup), + isEmpty, + ); + }); + + test('page switch persists old intent and never waits indefinitely', + () async { + final pageOne = _page('page-1', 0); + final pageTwo = _page('page-2', 1); + final first = _pageSnapshot(pageOne, text: 'one'); + final second = _pageSnapshot(pageTwo, text: 'two'); + final remote = _FakeRemoteEditorNotifier( + _editorSnapshot(pages: [pageOne, pageTwo], activePage: first), + pageCatalog: {'page-1': first, 'page-2': second}, + ); + final queue = _FakeStrategyOpQueueNotifier(blockFlush: true); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-page-1', position: const Offset(5, 5))..text = 'one', + ]); + container + .read(textDraftProvider.notifier) + .setDraft('text-page-1', 'unsent draft'); + + await session.setActivePage('page-2').timeout(const Duration(seconds: 2)); + expect(session.activePageId, 'page-2'); + expect(remote.selectedPageIds, contains('page-2')); + expect(container.read(textProvider).single.text, 'two'); + expect(container.read(textDraftProvider), isEmpty); + expect(queue.flushNowCount, 1); + expect( + container.read(strategyOpQueueProvider).pending.any((pending) => + pending.op.entityPublicId == 'text-page-1' && + pending.op.payload.toString().contains('unsent draft')), + isTrue, + ); + }); + + test('persisted overlay wins over a late remote active-page base', () async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote-before'), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'local', position: const Offset(5, 5)) + ..text = 'local-intent', + ]); + await session.flushCurrentPage(); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot(page, text: 'remote-after'), + )); + await _settle(); + expect(container.read(textProvider).single.text, 'local-intent'); + expect(container.read(strategyOpQueueProvider).pending, isNotEmpty); + }); + + test( + 'collaborator edits stay based on the page this client actually hydrated', + () async { + final page = _page('page-1', 0); + const localTextId = 'local-text'; + const collaboratorTextId = 'collaborator-text'; + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'shared-before', + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-before', + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + final queue = _FakeStrategyOpQueueNotifier(); + final container = await _cloudContainer(remote: remote, queue: queue); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + + container.read(textProvider.notifier).commitText( + localTextId, + 'this-client-edit', + ); + await _settle(); + + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + elements: [ + _textElement( + page.publicId, + localTextId, + 'collaborator-winner', + revision: 2, + worldSized: true, + ), + _textElement( + page.publicId, + collaboratorTextId, + 'collaborator-after', + revision: 2, + sortIndex: 1, + worldSized: true, + ), + ], + ), + )); + await _settle(); + + expect( + container + .read(textProvider) + .firstWhere((text) => text.id == collaboratorTextId) + .text, + 'collaborator-before', + ); + + await session.flushCurrentPage(); + + final elementOps = container + .read(strategyOpQueueProvider) + .queuedByEntityKey + .entries + .where((entry) => entry.key.kind == EntitySyncKeyKind.element) + .toList(growable: false); + expect(elementOps, hasLength(1)); + expect(elementOps.single.key.entityId, localTextId); + expect(elementOps.single.value.pending.op.expectedRevision, 1); + expect( + elementOps.single.value.pending.op.payload.toString(), + contains('this-client-edit'), + ); + }); + + test('local mode page switching keeps its shipped Hive shape', () async { + final box = await _openStrategyBox(); + final now = DateTime.utc(2026); + StrategyPage localPage(String id, int index, String value) => StrategyPage( + id: id, + name: 'Page ${index + 1}', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: [ + PlacedText(id: 'text-$id', position: const Offset(1, 2)) + ..text = value, + ], + imageData: const [], + utilityData: const [], + sortIndex: index, + isAttack: true, + settings: StrategySettings(), + ); + await box.put( + 'local-strategy', + StrategyData( + id: 'local-strategy', + name: 'Local', + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: now, + folderID: null, + pages: [ + localPage('page-1', 0, 'one'), + localPage('page-2', 1, 'two'), + ], + ), + ); + final container = ProviderContainer(overrides: [ + strategyOpQueueProvider.overrideWith( + () => _FakeStrategyOpQueueNotifier(), + ), + ]); + addTearDown(container.dispose); + container.read(strategyProvider.notifier).setFromState(const StrategyState( + strategyId: 'local-strategy', + strategyName: 'Local', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + )); + final session = container.read(strategyPageSessionProvider.notifier); + await session.initializeForStrategy( + strategyId: 'local-strategy', + source: StrategySource.local, + selectFirstPageIfNeeded: true, + ); + expect(container.read(textProvider).single.text, 'one'); + await session.setActivePage('page-2'); + expect(container.read(textProvider).single.text, 'two'); + expect(box.get('local-strategy')!.pages, hasLength(2)); + }); + + group('undo on a shared page keeps teammates\' work', () { + /// Page settings that round-trip exactly, so live sync authors nothing + /// for them unless undo changes them. + CloudPayload settingsWith({ + double agentSize = 30, + double abilitySize = 20, + }) => + StrategySettings(agentSize: agentSize, abilitySize: abilitySize) + .toJson(); + + Map payloadOf(Object value) { + return switch (value) { + PlacedAgentNode() => {...value.toJson(), 'elementType': 'agent'}, + PlacedAbility() => {...value.toJson(), 'elementType': 'ability'}, + DrawingElement() => { + ...(jsonDecode(DrawingProvider.objectToJson([value])) as List) + .single as Map, + 'elementType': 'drawing', + }, + PlacedText() => {...value.toJson(), 'elementType': 'text'}, + PlacedImage() => { + ...cloudImagePayloadFromPlacedImage(value), + 'elementType': 'image', + }, + PlacedUtility() => {...value.toJson(), 'elementType': 'utility'}, + _ => throw ArgumentError(value.runtimeType), + }; + } + + String idOf(Object value) => switch (value) { + PlacedWidget() => value.id, + DrawingElement() => value.id, + _ => throw ArgumentError(value.runtimeType), + }; + + RemoteElement element( + RemotePage page, + Object value, { + int revision = 1, + int sortIndex = 0, + }) { + final payload = payloadOf(value); + final kind = payload['elementType'] as String; + return RemoteElement( + publicId: idOf(value), + strategyPublicId: 'cloud-strategy', + pagePublicId: page.publicId, + elementType: kind, + payload: cloudElementPayload(kind: kind, data: payload), + sortIndex: sortIndex, + revision: revision, + deleted: false, + ); + } + + /// Everything on the local canvas outside lineups. + List canvasOf(ProviderContainer container) => [ + ...container.read(agentProvider), + ...container.read(abilityProvider), + ...container.read(drawingProvider).elements, + ...container.read(textProvider), + ...container.read(placedImageProvider).images, + ...container.read(utilityProvider), + ]; + + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> open( + List objects, { + List lineups = const [], + }) async { + final page = _page('page-1', 0); + final remote = _FakeRemoteEditorNotifier(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + settings: settingsWith(), + elements: [ + for (var i = 0; i < objects.length; i++) + element(page, objects[i], sortIndex: i), + ], + lineups: lineups, + ), + )); + final container = await _cloudContainer( + remote: remote, + queue: _FakeStrategyOpQueueNotifier(), + ); + // Let queued sync work finish before the container is disposed. + addTearDown(_settle); + await container + .read(strategyPageSessionProvider.notifier) + .initializeForStrategy( + strategyId: 'cloud-strategy', + source: StrategySource.cloud, + selectFirstPageIfNeeded: true, + ); + return (container, remote, page); + } + + /// This client's edits land and the page rehydrates with the server's + /// copy: the local canvas as sent, with [teammate] applied on top (a + /// teammate's change arriving in the same round). + Future land( + ProviderContainer container, + _FakeRemoteEditorNotifier remote, + RemotePage page, { + List Function(List canvas)? teammate, + CloudPayload? settings, + List lineups = const [], + }) async { + final canvas = canvasOf(container); + final stored = teammate == null ? canvas : teammate(canvas); + container.read(strategySaveStateProvider.notifier).markPersisted(); + remote.setSnapshot(_editorSnapshot( + pages: [page], + activePage: _pageSnapshot( + page, + contentRevision: 2, + settings: + settings ?? container.read(strategySettingsProvider).toJson(), + elements: [ + for (var i = 0; i < stored.length; i++) + element(page, stored[i], revision: 2, sortIndex: i), + ], + lineups: lineups, + ), + )); + await _settle(); + } + + Map desiredOps( + ProviderContainer container, + RemotePage page, + ) { + return container.read(activePageLiveSyncProvider.notifier).syncLocalPage( + strategyPublicId: 'cloud-strategy', + pageId: page.publicId, + ) ?? + const {}; + } + + /// [id] is on the canvas and live sync authors no deletion for it. + void expectKept(ProviderContainer container, RemotePage page, String id) { + expect(canvasOf(container).map(idOf), contains(id)); + expect( + desiredOps(container, page)[EntitySyncKey.element(page.publicId, id)], + isNot(isA()), + ); + } + + PlacedAgent jett(String id) => PlacedAgent( + id: id, + type: AgentType.jett, + position: const Offset(10, 20), + ); + + PlacedUtility cone(String id) => PlacedUtility( + id: id, + type: UtilityType.viewCone90, + position: const Offset(40, 40), + ); + + ActionProvider history(ProviderContainer container) => + container.read(actionProvider.notifier); + + test('undoing an agent move keeps an agent a teammate added', () async { + final (container, remote, page) = await open([jett('jett')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(200, 200), 'jett'); + await land(container, remote, page, + teammate: (canvas) => [...canvas, jett('sova')]); + + history(container).undoAction(); + + expect( + container + .read(agentProvider) + .firstWhere((a) => a.id == 'jett') + .position, + const Offset(10, 20), + ); + expectKept(container, page, 'sova'); + }); + + test('undoing an agent move keeps a teammate marking it dead', () async { + final (container, remote, page) = await open([jett('jett')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(200, 200), 'jett'); + await land(container, remote, page, + teammate: (canvas) => [ + for (final object in canvas) + if (object is PlacedAgent) + (object.copyWith()..state = AgentState.dead) + else + object, + ]); + + history(container).undoAction(); + + final agent = container.read(agentProvider).single; + expect(agent.position, const Offset(10, 20)); + expect(agent.state, AgentState.dead); + + history(container).redoAction(); + final redone = container.read(agentProvider).single; + expect(redone.position, const Offset(200, 200)); + expect(redone.state, AgentState.dead); + }); + + // A move, undone and redone, against a teammate's change to another + // field of the same object: [field] set to [value] in its payload. + final moveCases = <(String, Object, String, Object)>[ + ( + 'ability', + PlacedAbility( + id: 'mine', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(10, 20), + ), + 'isAlly', + false, + ), + ( + 'text', + PlacedText(id: 'mine', position: const Offset(10, 20))..text = 'mine', + 'text', + 'theirs', + ), + ( + 'image', + PlacedImage( + id: 'mine', + position: const Offset(10, 20), + aspectRatio: 1, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + ), + 'scale', + ImageScalePolicy.defaultWidth + 40, + ), + ( + 'utility', + PlacedUtility( + id: 'mine', + type: UtilityType.viewCone90, + position: const Offset(10, 20), + ), + 'isAlly', + false, + ), + ]; + for (final (kind, object, field, value) in moveCases) { + test('undoing a $kind move keeps a teammate change to its $field', + () async { + final (container, remote, page) = await open([object]); + void move(Offset to) => switch (kind) { + 'ability' => container + .read(abilityProvider.notifier) + .updatePosition(to, 'mine'), + 'text' => container + .read(textProvider.notifier) + .updatePosition(to, 'mine'), + 'image' => container + .read(placedImageProvider.notifier) + .updatePosition(to, 'mine'), + _ => container + .read(utilityProvider.notifier) + .updatePosition(to, 'mine'), + }; + Map mine() => payloadOf( + canvasOf(container).singleWhere((o) => idOf(o) == 'mine')); + Object withField(Object source) { + final json = {...payloadOf(source), field: value}; + return switch (kind) { + 'ability' => PlacedAbility.fromJson(json), + 'text' => PlacedText.fromJson(json), + 'image' => PlacedImage.fromJson(json), + _ => PlacedUtility.fromJson(json), + }; + } + + move(const Offset(200, 200)); + await land(container, remote, page, + teammate: (canvas) => [for (final o in canvas) withField(o)]); + expect(mine()[field], value); + + history(container).undoAction(); + expect(mine()['position'], {'dx': 10.0, 'dy': 20.0}); + expect(mine()[field], value); + + history(container).redoAction(); + expect(mine()['position'], {'dx': 200.0, 'dy': 200.0}); + expect(mine()[field], value); + }); + } + + test('undoing a view cone conversion keeps an agent a teammate added', + () async { + final (container, remote, page) = await open([jett('jett')]); + + history(container).performTransaction( + groups: const [ActionGroup.agent], + mutation: () => + container.read(agentProvider.notifier).convertPlainAgentToViewCone( + id: 'jett', + presetType: UtilityType.viewCone90, + rotation: 0, + length: 50, + ), + ); + await land(container, remote, page, + teammate: (canvas) => [...canvas, jett('sova')]); + + history(container).undoAction(); + + expect( + container.read(agentProvider).firstWhere((a) => a.id == 'jett'), + isNot(isA()), + ); + expectKept(container, page, 'sova'); + + history(container).redoAction(); + expect( + container.read(agentProvider).firstWhere((a) => a.id == 'jett'), + isA(), + ); + expectKept(container, page, 'sova'); + }); + + test('undoing a cone dropped on an agent keeps a teammate utility', + () async { + final (container, remote, page) = + await open([jett('jett'), cone('cone')]); + + history(container).performTransaction( + groups: const [ActionGroup.agent, ActionGroup.utility], + mutation: () { + container + .read(utilityProvider.notifier) + .removeUtilityAsAction('cone'); + container.read(agentProvider.notifier).convertPlainAgentToViewCone( + id: 'jett', + presetType: UtilityType.viewCone90, + rotation: 0, + length: 50, + ); + }, + ); + await land(container, remote, page, + teammate: (canvas) => [...canvas, cone('teammate-cone')]); + + history(container).undoAction(); + + expect( + container.read(utilityProvider).map((u) => u.id), + containsAll(['cone', 'teammate-cone']), + ); + expect( + container.read(agentProvider).single, + isNot(isA()), + ); + expectKept(container, page, 'teammate-cone'); + + history(container).redoAction(); + expect( + container.read(utilityProvider).map((u) => u.id), + ['teammate-cone'], + ); + expectKept(container, page, 'teammate-cone'); + }); + + test('undoing a size change keeps a teammate size change', () async { + final (container, remote, page) = await open([jett('jett')]); + + final settings = container.read(strategySettingsProvider.notifier); + history(container).performTransaction( + groups: const [ActionGroup.strategySettings], + mutation: () => settings.updateAgentSize(40), + ); + await land(container, remote, page, + settings: settingsWith(agentSize: 40, abilitySize: 28)); + expect(container.read(strategySettingsProvider).abilitySize, 28); + + history(container).undoAction(); + + expect(container.read(strategySettingsProvider).agentSize, 30); + expect(container.read(strategySettingsProvider).abilitySize, 28); + + history(container).redoAction(); + expect(container.read(strategySettingsProvider).agentSize, 40); + expect(container.read(strategySettingsProvider).abilitySize, 28); + }); + + test('undoing a utility elevation change still works after it lands', + () async { + final (container, remote, page) = await open([cone('cone')]); + + container + .read(utilityProvider.notifier) + .updateViewConeElevation('cone', 150); + await land(container, remote, page); + + history(container).undoAction(); + expect(container.read(utilityProvider).single.visionElevation, isNull); + + history(container).redoAction(); + expect(container.read(utilityProvider).single.visionElevation, 150); + }); + + test('an edit before a deletion stays undoable after the deletion lands', + () async { + final (container, remote, page) = await open([jett('jett')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(200, 200), 'jett'); + container.read(agentProvider.notifier).removeAgentAsAction('jett'); + await land(container, remote, page); + expect(container.read(agentProvider), isEmpty); + + history(container).undoAction(); + expect( + container.read(agentProvider).single.position, + const Offset(200, 200), + ); + + history(container).undoAction(); + expect( + container.read(agentProvider).single.position, + const Offset(10, 20), + ); + }); + + test('undo and redo skip an edit whose object a teammate deleted', + () async { + final (container, remote, page) = + await open([jett('jett'), jett('sova')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(200, 200), 'jett'); + await land(container, remote, page, + teammate: (canvas) => [ + for (final object in canvas) + if (idOf(object) != 'jett') object, + ]); + + history(container).undoAction(); + history(container).redoAction(); + + expect(container.read(agentProvider).map((a) => a.id), ['sova']); + expect( + desiredOps( + container, page)[EntitySyncKey.element(page.publicId, 'jett')], + isNull, + ); + }); + + // Bulk clear, per group: a teammate's object of the cleared kind arrives + // after the clear lands. + final clearCases = <(ActionGroup, Object Function(String id))>[ + (ActionGroup.agent, jett), + ( + ActionGroup.ability, + (id) => PlacedAbility( + id: id, + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(60, 60), + ), + ), + ( + ActionGroup.drawing, + (id) => FreeDrawing( + id: id, + color: Colors.red, + isDotted: false, + hasArrow: false, + listOfPoints: const [Offset.zero, Offset(10, 10)], + ), + ), + ( + ActionGroup.text, + (id) => PlacedText(id: id, position: const Offset(70, 70))..text = id, + ), + ( + ActionGroup.image, + (id) => PlacedImage( + id: id, + position: const Offset(80, 80), + aspectRatio: 1, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + ), + ), + (ActionGroup.utility, cone), + ]; + for (final (group, build) in clearCases) { + test('undoing a ${group.name} clear keeps one a teammate added', + () async { + final (container, remote, page) = await open([ + build('mine'), + if (group != ActionGroup.agent) jett('jett'), + ]); + + history(container).clearGroupAsAction(group); + expect(canvasOf(container).map(idOf), isNot(contains('mine'))); + await land(container, remote, page, + teammate: (canvas) => [...canvas, build('theirs')]); + + history(container).undoAction(); + expect( + canvasOf(container).map(idOf), + containsAll(['mine', 'theirs']), + ); + expectKept(container, page, 'theirs'); + + history(container).redoAction(); + expect(canvasOf(container).map(idOf), isNot(contains('mine'))); + expectKept(container, page, 'theirs'); + }); + } + + /// [canvas] with the object [id] replaced by [edit] of it. + List editing( + List canvas, + String id, + Object Function(Object object) edit, + ) => + [for (final o in canvas) idOf(o) == id ? edit(o) : o]; + + /// [canvas] without the object [id]. + List without(List canvas, String id) => [ + for (final o in canvas) + if (idOf(o) != id) o + ]; + + Object dead(Object agent) => + (agent as PlacedAgent).copyWith()..state = AgentState.dead; + + PlacedAgent agentOn(ProviderContainer container, String id) => + container.read(agentProvider).singleWhere((a) => a.id == id) + as PlacedAgent; + + test('undoing an ability visibility toggle keeps teammate toggles', + () async { + final ability = PlacedAbility( + id: 'mine', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(10, 20), + ); + final (container, remote, page) = await open([ability]); + AbilityVisualState visual() => + container.read(abilityProvider).single.visualState; + Object toggled(Object object, String toggle) => PlacedAbility.fromJson({ + ...payloadOf(object), + 'visualState': { + ...(object as PlacedAbility).visualState.toJson(), + toggle: false, + }, + }); + + container.read(abilityProvider.notifier).updateVisualState( + 0, + visual().copyWith(showRangeOutline: false), + ); + await land(container, remote, page, + teammate: (canvas) => + editing(canvas, 'mine', (o) => toggled(o, 'showRangeFill'))); + + history(container).undoAction(); + expect(visual().showRangeOutline, isTrue); + expect(visual().showRangeFill, isFalse); + + // Another teammate toggle lands between undo and redo. + await land(container, remote, page, + teammate: (canvas) => + editing(canvas, 'mine', (o) => toggled(o, 'showInnerFill'))); + history(container).redoAction(); + expect(visual().showRangeOutline, isFalse); + expect(visual().showRangeFill, isFalse); + expect(visual().showInnerFill, isFalse); + + history(container).undoAction(); + expect(visual().showRangeOutline, isTrue); + expect(visual().showRangeFill, isFalse); + expect(visual().showInnerFill, isFalse); + }); + + test('undoing a deletion leaves a teammate copy that is still there', + () async { + final (container, remote, page) = await open([jett('jett')]); + + container.read(agentProvider.notifier).removeAgentAsAction('jett'); + // The teammate's edited copy wins on the server. + final teammateCopy = dead(jett('jett')); + await land(container, remote, page, + teammate: (canvas) => [...canvas, teammateCopy]); + expect(agentOn(container, 'jett').state, AgentState.dead); + + history(container).undoAction(); + expect(agentOn(container, 'jett').state, AgentState.dead); + + // The undo restored nothing, so there is nothing for redo to remove. + history(container).redoAction(); + expect(agentOn(container, 'jett').state, AgentState.dead); + expect( + desiredOps( + container, page)[EntitySyncKey.element(page.publicId, 'jett')], + isNot(isA()), + ); + }); + + test('redoing an addition brings back the copy the undo took away', + () async { + final (container, remote, page) = await open([jett('jett')]); + + container.read(agentProvider.notifier).addAgent(jett('sova')); + await land(container, remote, page, + teammate: (canvas) => editing(canvas, 'sova', dead)); + + history(container).undoAction(); + expect(container.read(agentProvider).map((a) => a.id), ['jett']); + await land(container, remote, page); + + history(container).redoAction(); + expect(agentOn(container, 'sova').state, AgentState.dead); + + history(container).undoAction(); + history(container).redoAction(); + expect(agentOn(container, 'sova').state, AgentState.dead); + }); + + test('a clear undone and redone around a teammate edit keeps the edit', + () async { + final (container, remote, page) = await open([jett('jett')]); + + history(container).clearGroupAsAction(ActionGroup.agent); + history(container).undoAction(); + await land(container, remote, page, + teammate: (canvas) => editing(canvas, 'jett', dead)); + + history(container).redoAction(); + expect(container.read(agentProvider), isEmpty); + await land(container, remote, page); + + history(container).undoAction(); + expect(agentOn(container, 'jett').state, AgentState.dead); + }); + + test('undoing an addition a teammate deleted gives redo nothing to add', + () async { + final (container, remote, page) = await open([jett('jett')]); + + container.read(agentProvider.notifier).addAgent(jett('sova')); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'sova')); + + history(container).undoAction(); + history(container).redoAction(); + + expect(container.read(agentProvider).map((a) => a.id), ['jett']); + expect( + desiredOps( + container, page)[EntitySyncKey.element(page.publicId, 'sova')], + isNull, + ); + }); + + test( + 'redoing a clear after a teammate deleted a restored object ' + 'does not bring it back on the next undo', () async { + final (container, remote, page) = + await open([jett('jett'), jett('sova')]); + + history(container).clearGroupAsAction(ActionGroup.agent); + history(container).undoAction(); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'sova')); + + history(container).redoAction(); + expect(container.read(agentProvider), isEmpty); + await land(container, remote, page); + + history(container).undoAction(); + expect(container.read(agentProvider).map((a) => a.id), ['jett']); + expect( + desiredOps( + container, page)[EntitySyncKey.element(page.publicId, 'sova')], + isNull, + ); + }); + + test('a transaction whose object a teammate deleted leaves history', + () async { + final (container, remote, page) = await open([jett('jett')]); + + history(container).performTransaction( + groups: const [ActionGroup.agent], + mutation: () => + container.read(agentProvider.notifier).convertPlainAgentToViewCone( + id: 'jett', + presetType: UtilityType.viewCone90, + rotation: 0, + length: 50, + ), + ); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'jett')); + + expect(container.read(actionProvider), isEmpty); + }); + + test('undoing a clear leaves out history hydration dropped since', + () async { + final (container, remote, page) = await open([ + jett('jett'), + PlacedText(id: 'note', position: const Offset(70, 70))..text = 'note', + ]); + + container + .read(textProvider.notifier) + .updatePosition(const Offset(300, 300), 'note'); + history(container).clearGroupAsAction(ActionGroup.agent); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'note')); + + history(container).undoAction(); + expect(container.read(agentProvider).map((a) => a.id), ['jett']); + expect(container.read(actionProvider), isEmpty); + expect(container.read(textProvider), isEmpty); + }); + + /// [lineup]'s row as a teammate left it, with [notes]. + List withNotes(RemoteLineup lineup, String notes) => + [_editedLineup(lineup, (link) => link['notes'] = notes)]; + + test( + 'redoing a lineup clear a teammate already undid by deleting ' + 'does not resurrect it on the next undo', () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + history(container).clearGroupAsAction(ActionGroup.lineUp); + history(container).undoAction(); + expect(container.read(lineUpProvider).origins, hasLength(1)); + await land(container, remote, page); // the teammate deleted it + + history(container).redoAction(); + history(container).undoAction(); + + expect(container.read(lineUpProvider).links, isEmpty); + expect( + desiredOps(container, page) + .keys + .where((key) => key == _lineupKey(page.publicId, 'mine')), + isEmpty, + ); + }); + + test('a lineup clear redone and undone keeps a teammate notes edit', + () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + history(container).clearGroupAsAction(ActionGroup.lineUp); + history(container).undoAction(); + await land(container, remote, page, + lineups: withNotes(mine, 'teammate notes')); + expect( + container.read(lineUpProvider).links.single.notes, 'teammate notes'); + + history(container).redoAction(); + expect(container.read(lineUpProvider).links, isEmpty); + await land(container, remote, page); + + history(container).undoAction(); + expect( + container.read(lineUpProvider).links.single.notes, 'teammate notes'); + }); + + test( + 'undoing a lineup clear leaves a lineup a teammate restored, ' + 'and redo does not remove it', () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + history(container).clearGroupAsAction(ActionGroup.lineUp); + await land(container, remote, page, lineups: withNotes(mine, 'restored')); + expect(container.read(lineUpProvider).links.single.notes, 'restored'); + + history(container).undoAction(); + history(container).redoAction(); + + expect(container.read(lineUpProvider).links.single.notes, 'restored'); + expect( + desiredOps(container, page).values.whereType(), + isEmpty, + ); + }); + + test( + 'redoing a lineup deletion a teammate already made removes nothing ' + 'and the next undo does not resurrect it', () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + container.read(lineUpProvider.notifier).deleteOrigin('mine'); + history(container).undoAction(); + expect(container.read(lineUpProvider).links, hasLength(1)); + await land(container, remote, page); // the teammate deleted it + + history(container).redoAction(); + expect(container.read(lineUpProvider).links, isEmpty); + history(container).undoAction(); + + expect(container.read(lineUpProvider).links, isEmpty); + expect( + desiredOps(container, page) + .keys + .where((key) => key == _lineupKey(page.publicId, 'mine')), + isEmpty, + ); + }); + + test('undoing a weapon change on an agent a teammate deleted does nothing', + () async { + final (container, remote, page) = await open([jett('jett')]); + + container + .read(agentProvider.notifier) + .setWeapon('jett', WeaponType.classic); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'jett')); + + history(container).undoAction(); + + expect(container.read(agentProvider), isEmpty); + expect(history(container).poppedItems, isEmpty); + }); + + test( + 'a weapon change on an agent a teammate deleted leaves history, ' + 'so the next undo undoes the change before it', () async { + final (container, remote, page) = + await open([jett('jett'), jett('sova')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(200, 200), 'sova'); + container + .read(agentProvider.notifier) + .setWeapon('jett', WeaponType.classic); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'jett')); + expect(container.read(actionProvider), hasLength(1)); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + }); + + test('a lineup weapon change a teammate deleted leaves history', () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + container + .read(lineUpProvider.notifier) + .setOriginWeapon('mine', WeaponType.classic); + await land(container, remote, page); + + expect(container.read(actionProvider), isEmpty); + }); + + test('undoing a lineup weapon change a teammate deleted does nothing', + () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + + container + .read(lineUpProvider.notifier) + .setOriginWeapon('mine', WeaponType.classic); + await land(container, remote, page); + + history(container).undoAction(); + + expect(container.read(lineUpProvider).origins, isEmpty); + expect(history(container).poppedItems, isEmpty); + }); + + test( + 'undoing a lineup notes edit whose link a teammate deleted does ' + 'nothing', () async { + final mine = _lineup('page-1', 'mine'); + final (container, remote, page) = await open(const [], lineups: [mine]); + final lineUps = container.read(lineUpProvider.notifier); + + lineUps.updateLink( + container.read(lineUpProvider).links.single.copyWith(notes: 'mine'), + ); + lineUps.deleteOrigin('mine'); + history(container).undoAction(); // the deletion + await land(container, remote, page); // the teammate deleted it + + // Nothing left can change anything: undo never reaches the deletion on + // the redo stack, so the edit's link cannot come back for it, and + // redoing the deletion would remove nothing. Both steps go. + expect(container.read(actionProvider), isEmpty); + expect(history(container).poppedItems, isEmpty); + + history(container).undoAction(); + expect(container.read(lineUpProvider).links, isEmpty); + }); + + Map image(String id) => + {'id': id, 'fileExtension': '.png'}; + + List linkImages(ProviderContainer container) => [ + for (final image + in container.read(lineUpProvider).links.single.images) + image.id, + ]; + + test( + 'redoing an image removal a teammate already made, then undoing, ' + 'does not bring the image back', () async { + final mine = _editedLineup( + _lineup('page-1', 'mine'), + (link) => link['images'] = [image('x'), image('y')], + ); + final (container, remote, page) = await open(const [], lineups: [mine]); + final link = container.read(lineUpProvider).links.single; + + container.read(lineUpProvider.notifier).updateLink( + link.copyWith( + images: link.images.where((i) => i.id != 'x').toList(), + ), + ); + history(container).undoAction(); + expect(linkImages(container), ['x', 'y']); + // A teammate removes x too. + await land(container, remote, page, lineups: [ + _editedLineup(mine, (link) => link['images'] = [image('y')]), + ]); + expect(linkImages(container), ['y']); + + history(container).redoAction(); + expect(linkImages(container), ['y']); + history(container).undoAction(); + expect(linkImages(container), ['y']); + }); + + test( + 'an edit that removed an image and changed notes replays only the ' + 'notes once a teammate removed the image', () async { + final mine = _editedLineup( + _lineup('page-1', 'mine'), + (link) => link['images'] = [image('x'), image('y')], + ); + final (container, remote, page) = await open(const [], lineups: [mine]); + final link = container.read(lineUpProvider).links.single; + + container.read(lineUpProvider.notifier).updateLink( + link.copyWith( + notes: 'mine', + images: link.images.where((i) => i.id != 'x').toList(), + ), + ); + history(container).undoAction(); + await land(container, remote, page, lineups: [ + _editedLineup(mine, (link) => link['images'] = [image('y')]), + ]); + + history(container).redoAction(); + expect(container.read(lineUpProvider).links.single.notes, 'mine'); + expect(linkImages(container), ['y']); + + history(container).undoAction(); + expect( + container.read(lineUpProvider).links.single.notes, + 'remote lineup', + ); + expect(linkImages(container), ['y']); + }); + + test( + 'an agent a teammate deleted does not swallow the next undo: it ' + 'undoes the move made before', () async { + final (container, remote, page) = await open([jett('sova')]); + final agents = container.read(agentProvider.notifier); + + agents.addAgent(jett('jett')); + agents.updatePosition(const Offset(300, 300), 'sova'); + agents.updatePosition(const Offset(200, 200), 'jett'); + await land(container, remote, page, + teammate: (canvas) => without(canvas, 'jett')); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + }); + + test( + 'a lineup a teammate deleted does not swallow the next undo: it ' + 'undoes the move made before', () async { + final (container, remote, page) = await open([jett('sova')]); + final lineUps = container.read(lineUpProvider.notifier)..startFresh(); + lineUps.setDraftAgent(PlacedAgent( + id: 'agent-new', + type: AgentType.sova, + position: const Offset(100, 100), + )); + lineUps.setDraftAbility(PlacedAbility( + id: 'ability-new', + data: AgentData.agents[AgentType.sova]!.abilities[2], + position: const Offset(300, 300), + )); + final placed = lineUps.commitPlacement()!; + container + .read(agentProvider.notifier) + .updatePosition(const Offset(300, 300), 'sova'); + lineUps.setOriginWeapon(placed.originId, WeaponType.classic); + // The teammate deletes the new lineup. + await land(container, remote, page, lineups: const []); + expect(container.read(lineUpProvider).links, isEmpty); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + }); + + test( + 'a weapon change a teammate already reverted is skipped: undo ' + 'undoes the move made before', () async { + final (container, remote, page) = await open([ + jett('jett')..weapon = WeaponType.classic, + jett('sova'), + ]); + final agents = container.read(agentProvider.notifier); + + agents.updatePosition(const Offset(300, 300), 'sova'); + agents.setWeapon('jett', WeaponType.vandal); + // A teammate sets Jett back to the Classic. + await land(container, remote, page, + teammate: (canvas) => editing( + canvas, + 'jett', + (o) => + (o as PlacedAgent).copyWith()..weapon = WeaponType.classic, + )); + expect(agentOn(container, 'jett').weapon, WeaponType.classic); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + expect(agentOn(container, 'jett').weapon, WeaponType.classic); + }); + + test( + 'a visibility toggle a teammate already reverted is skipped: undo ' + 'undoes the move made before', () async { + final ability = PlacedAbility( + id: 'mine', + data: AgentData.agents[AgentType.jett]!.abilities.first, + position: const Offset(60, 60), + ); + final (container, remote, page) = await open([ability, jett('sova')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(300, 300), 'sova'); + final abilities = container.read(abilityProvider.notifier); + abilities.updateVisualState( + 0, + container + .read(abilityProvider) + .single + .visualState + .copyWith(showRangeOutline: false), + ); + // A teammate turns the outline back on. + await land(container, remote, page, + teammate: (canvas) => editing( + canvas, + 'mine', + (o) => PlacedAbility.fromJson({ + ...payloadOf(o), + 'visualState': { + ...(o as PlacedAbility).visualState.toJson(), + 'showRangeOutline': true, + }, + }), + )); + expect( + container.read(abilityProvider).single.visualState.showRangeOutline, + isTrue, + ); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + }); + + test( + 'an edit a teammate partly reverted undoes and redoes only the ' + 'fields they left alone', () async { + const white = 0xFFFFFFFF; + const red = 0xFFFF0000; + final (container, remote, page) = await open([ + PlacedCircleAgent( + id: 'circle', + type: AgentType.jett, + position: const Offset(10, 20), + diameterMeters: 5, + colorValue: white, + opacityPercent: 60, + ), + ]); + PlacedCircleAgent circle() => + container.read(agentProvider).single as PlacedCircleAgent; + + // One edit changes the diameter and the colour. + container.read(agentProvider.notifier).updateCircleGeometry( + id: 'circle', + diameterMeters: 8, + colorValue: red, + opacityPercent: 60, + ); + // A teammate sets the colour back to white; the diameter stays 8. + await land(container, remote, page, + teammate: (canvas) => [ + for (final o in canvas) + PlacedAgentNode.fromJson({ + ...payloadOf(o)..remove('elementType'), + 'colorValue': white, + }), + ]); + expect((circle().diameterMeters, circle().colorValue), (8.0, white)); + + history(container).undoAction(); + expect((circle().diameterMeters, circle().colorValue), (5.0, white)); + + history(container).redoAction(); + expect((circle().diameterMeters, circle().colorValue), (8.0, white)); + + history(container).undoAction(); + expect((circle().diameterMeters, circle().colorValue), (5.0, white)); + }); + + test('a tiny move is still a move: undo puts the agent back', () async { + final (container, remote, page) = await open([jett('jett')]); + + container + .read(agentProvider.notifier) + .updatePosition(const Offset(10.001, 20), 'jett'); + await land(container, remote, page); + expect(agentOn(container, 'jett').position, const Offset(10.001, 20)); + + history(container).undoAction(); + expect(agentOn(container, 'jett').position, const Offset(10, 20)); + expect( + desiredOps( + container, page)[EntitySyncKey.element(page.publicId, 'jett')], + isA(), + ); + + history(container).redoAction(); + expect(agentOn(container, 'jett').position, const Offset(10.001, 20)); + }); + + PlacedAgentNode jettNode(ProviderContainer container) => + container.read(agentProvider).singleWhere((a) => a.id == 'jett'); + + /// Drops a view cone utility onto Jett as one step, the way the editor + /// does, after moving Sova. + Future<(ProviderContainer, _FakeRemoteEditorNotifier, RemotePage)> + coneOnJettAfterSovaMove() async { + final opened = await open([jett('jett'), jett('sova'), cone('cone')]); + final container = opened.$1; + container + .read(agentProvider.notifier) + .updatePosition(const Offset(300, 300), 'sova'); + history(container).performTransaction( + groups: const [ActionGroup.agent, ActionGroup.utility], + mutation: () { + container + .read(utilityProvider.notifier) + .removeUtilityAsAction('cone'); + container.read(agentProvider.notifier).convertPlainAgentToViewCone( + id: 'jett', + presetType: UtilityType.viewCone90, + rotation: 0, + length: 50, + ); + }, + ); + return opened; + } + + test( + 'a transaction a teammate partly reverted undoes only what they ' + 'left', () async { + final (container, remote, page) = await coneOnJettAfterSovaMove(); + // A teammate puts the cone back; Jett stays a view cone agent. + await land(container, remote, page, + teammate: (canvas) => [...canvas, cone('cone')]); + + history(container).undoAction(); + + expect(jettNode(container), isNot(isA())); + expect(container.read(utilityProvider).map((u) => u.id), ['cone']); + // Sova's move is the next step, still to undo. + expect(agentOn(container, 'sova').position, const Offset(300, 300)); + expect( + desiredOps(container, page).values.whereType(), + isEmpty, + ); + + history(container).redoAction(); + expect(jettNode(container), isA()); + expect(container.read(utilityProvider).map((u) => u.id), ['cone']); + }); + + test( + 'a transaction a teammate fully reverted is skipped: undo undoes ' + 'the step before', () async { + final (container, remote, page) = await coneOnJettAfterSovaMove(); + // A teammate puts the cone back and turns Jett back into an agent. + await land(container, remote, page, + teammate: (canvas) => [ + for (final o in canvas) + if (idOf(o) == 'jett') jett('jett') else o, + cone('cone'), + ]); + + history(container).undoAction(); + + expect(agentOn(container, 'sova').position, const Offset(10, 20)); + expect(jettNode(container), isNot(isA())); + expect(container.read(utilityProvider).map((u) => u.id), ['cone']); + }); + + test('undoing a lineup clear keeps a lineup a teammate added', () async { + final (container, remote, page) = + await open(const [], lineups: [_lineup('page-1', 'mine')]); + + history(container).clearGroupAsAction(ActionGroup.lineUp); + expect(container.read(lineUpProvider).origins, isEmpty); + await land(container, remote, page, + lineups: [_lineup(page.publicId, 'theirs')]); + + history(container).undoAction(); + expect( + container.read(lineUpProvider).origins.map((o) => o.id), + containsAll(['mine', 'theirs']), + ); + expect( + desiredOps(container, page).values.whereType(), + isEmpty, + ); + + history(container).redoAction(); + expect( + container.read(lineUpProvider).origins.map((o) => o.id), + ['theirs'], + ); + }); + }); +} diff --git a/test/strategy_provider_save_queue_test.dart b/test/strategy_provider_save_queue_test.dart index 2523b9a8..5cd68ce4 100644 --- a/test/strategy_provider_save_queue_test.dart +++ b/test/strategy_provider_save_queue_test.dart @@ -10,7 +10,7 @@ class _ControlledSaveStrategyProvider extends StrategyProvider { int saveCalls = 0; @override - StrategyState build() => StrategyState( + StrategyState build() => const StrategyState( isSaved: false, stratName: 'Strategy', id: 'strategy-id', diff --git a/test/strategy_switch_side_test.dart b/test/strategy_switch_side_test.dart index 0e902457..2260cb0b 100644 --- a/test/strategy_switch_side_test.dart +++ b/test/strategy_switch_side_test.dart @@ -1,7 +1,7 @@ import 'dart:io'; -import 'dart:ui'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; import 'package:icarus/const/agents.dart'; @@ -60,6 +60,12 @@ void main() { setUpAll(() async { CoordinateSystem(playAreaSize: const Size(1920, 1080)); tempDir = await Directory.systemTemp.createTemp('icarus-switch-side'); + // Opening a local strategy resolves its image storage directory. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => tempDir.path, + ); Hive.init(tempDir.path); if (!Hive.isAdapterRegistered(20)) { registerIcarusAdapters(Hive); @@ -94,7 +100,7 @@ void main() { container = ProviderContainer(); final notifier = container.read(strategyProvider.notifier); - await notifier.renameStrategy(_strategyId, 'Mixed'); + await notifier.loadFromHive(_strategyId); await notifier.setActivePage('p2'); }); @@ -120,9 +126,7 @@ void main() { test('switching side on all pages unifies a mixed strategy', () async { expect(container.read(mapProvider).isAttack, isFalse); - await container - .read(strategyProvider.notifier) - .switchSide(allPages: true); + await container.read(strategyProvider.notifier).switchSide(allPages: true); expect(container.read(mapProvider).isAttack, isTrue); expect(container.read(strategyProvider).isSaved, isFalse); @@ -133,7 +137,7 @@ void main() { ); final notifier = container.read(strategyProvider.notifier); - await notifier.forceSaveNow(container.read(strategyProvider).id); + await notifier.forceSaveNow(_strategyId); var pages = storedPages(); expect(pages.map((p) => p.isAttack), everyElement(isTrue)); expect( @@ -151,9 +155,7 @@ void main() { }); test('switching side on this page leaves the other pages alone', () async { - await container - .read(strategyProvider.notifier) - .switchSide(allPages: false); + await container.read(strategyProvider.notifier).switchSide(allPages: false); expect(container.read(mapProvider).isAttack, isTrue); expect(container.read(strategyProvider).isSaved, isFalse); diff --git a/test/sunset_scale_migration_test.dart b/test/sunset_scale_migration_test.dart index f9ae0079..7f558803 100644 --- a/test/sunset_scale_migration_test.dart +++ b/test/sunset_scale_migration_test.dart @@ -13,6 +13,7 @@ import 'package:icarus/migrations/custom_circle_wrapper_migration.dart'; import 'package:icarus/migrations/paranoia_range_migration.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; const _oldScale = 0.9502102049421427; @@ -187,7 +188,7 @@ void main() { final before = jsonEncode( source.pages.map((p) => p.toJson(source.id)).toList(), ); - final result = StrategyProvider.migrateToCurrentVersion(source); + final result = StrategyMigrator.migrateToCurrentVersion(source); expect(Maps.mapScale[MapValue.sunset], _newScale); expect(result.versionNumber, Settings.versionNumber); expect(result.folderID, source.folderID); @@ -200,14 +201,14 @@ void main() { before, ); expect( - identical(StrategyProvider.migrateToCurrentVersion(result), result), + identical(StrategyMigrator.migrateToCurrentVersion(result), result), isTrue, ); }); test('other maps keep their pages unchanged', () { final source = _strategy(map: MapValue.split); - final result = StrategyProvider.migrateSunsetScale(source, force: true); + final result = StrategyMigrator.migrateSunsetScale(source, force: true); expect(identical(result.pages, source.pages), isTrue); }); @@ -215,7 +216,7 @@ void main() { final source = _strategy(version: 96, pages: [_page(false)]); final before = source.pages.single.abilityData .firstWhere((a) => a.data.name == 'Crosscut'); - final result = StrategyProvider.migrateToCurrentVersion(source); + final result = StrategyMigrator.migrateToCurrentVersion(source); final after = result.pages.single.abilityData.firstWhere((a) => a.id == before.id); final oldCenter = before.position + @@ -260,42 +261,42 @@ void main() { () async { final source = _strategy(version: version); // Explicit historical stages provide the pre-98 placement reference. - var historical = StrategyProvider.migrateToWorld16x9(source); + var historical = StrategyMigrator.migrateToWorld16x9(source); if (version < 39) - historical = StrategyProvider.migrateAbilityScale( + historical = StrategyMigrator.migrateAbilityScale( historical, force: true, ); if (version < 40) - historical = StrategyProvider.migrateSquareAoeCenter( + historical = StrategyMigrator.migrateSquareAoeCenter( historical, force: true, ); if (version < 45) - historical = StrategyProvider.migrateCustomCircleWrapper( + historical = StrategyMigrator.migrateCustomCircleWrapper( historical, force: true, ); if (version < 61) - historical = StrategyProvider.migrateLineUpGroups( + historical = StrategyMigrator.migrateLineUpGroups( historical, force: true, ); if (version < 95) - historical = StrategyProvider.migrateAbilityVisionCones( + historical = StrategyMigrator.migrateAbilityVisionCones( historical, force: true, ); if (version < 95) - historical = StrategyProvider.migratePageNameProvenance( + historical = StrategyMigrator.migratePageNameProvenance( historical, force: true, ); - historical = StrategyProvider.migrateCanonicalCoordinates( + historical = StrategyMigrator.migrateCanonicalCoordinates( historical, force: true, ); - final result = await StrategyProvider.migrateLegacyData(source); + final result = await StrategyMigrator.migrateLegacyData(source); for (var i = 0; i < historical.pages.length; i++) { _expectAnchors(historical.pages[i], result.pages[i]); } @@ -313,8 +314,8 @@ void main() { agentData: page.agentData.cast(), isAttack: false, ); - final result = await StrategyProvider.migrateLegacyData(old); - final paged = await StrategyProvider.migrateLegacyData( + final result = await StrategyMigrator.migrateLegacyData(old); + final paged = await StrategyMigrator.migrateLegacyData( _strategy(version: 15, pages: [page.copyWith(lineUpGroups: [])]), ); expect( @@ -332,7 +333,7 @@ void main() { test( 'zip JSON export/import preserves migrated placements without a second shift', () async { - final migrated = await StrategyProvider.migrateLegacyData(_strategy()); + final migrated = await StrategyMigrator.migrateLegacyData(_strategy()); final exportedPages = migrated.pages.map((p) => p.toJson(migrated.id)).toList(); final bytes = utf8.encode( @@ -354,7 +355,7 @@ void main() { strategyID: migrated.id, isZip: true, ); - final restored = await StrategyProvider.migrateLegacyData( + final restored = await StrategyMigrator.migrateLegacyData( migrated.copyWith( versionNumber: int.parse(decoded['versionNumber'] as String), pages: pages, diff --git a/test/text_editing_shortcut_scope_test.dart b/test/text_editing_shortcut_scope_test.dart index 2c238e78..ef2d92f0 100644 --- a/test/text_editing_shortcut_scope_test.dart +++ b/test/text_editing_shortcut_scope_test.dart @@ -108,6 +108,52 @@ void main() { expect(controller.text, isEmpty); }); + testWidgets('plain editor shortcuts stop at a focused text field', + (tester) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + var addPageInvocations = 0; + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.keyC): AddPageIntent(), + }, + child: Actions( + actions: >{ + AddPageIntent: CallbackAction( + onInvoke: (_) { + addPageInvocations++; + return null; + }, + ), + }, + child: Scaffold( + body: TextEditingShortcutScope( + child: TextField( + controller: controller, + autofocus: true, + ), + ), + ), + ), + ), + ), + ), + ); + + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.keyC, character: 'c'); + await tester.pump(); + tester.testTextInput.enterText('ICR-CARS-GO'); + await tester.pump(); + + expect(addPageInvocations, 0); + expect(controller.text, 'ICR-CARS-GO'); + }); + testWidgets('field-specific shortcuts take priority over text defaults', (tester) async { var submitInvocations = 0; diff --git a/test/text_provider_test.dart b/test/text_provider_test.dart index 1e24af87..3f6bd148 100644 --- a/test/text_provider_test.dart +++ b/test/text_provider_test.dart @@ -13,10 +13,12 @@ import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; class _NoopActionProvider extends ActionProvider { @override @@ -236,17 +238,23 @@ void main() { .setDraft('text-1', 'saved draft'); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: page.id, - ), - ) - ..activePageID = page.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await strategyNotifier.saveToHive(strategy.id); @@ -315,17 +323,23 @@ void main() { .setDraft('text-1', 'draft leaving page'); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: pageOne.id, - ), - ) - ..activePageID = pageOne.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1', 'page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await strategyNotifier.setActivePage(pageTwo.id); diff --git a/test/text_widget_resilience_test.dart b/test/text_widget_resilience_test.dart index 628df917..afda2a37 100644 --- a/test/text_widget_resilience_test.dart +++ b/test/text_widget_resilience_test.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; @@ -11,13 +12,16 @@ import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/hive/hive_registration.dart'; import 'package:icarus/providers/action_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; import 'package:icarus/providers/screenshot_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/draggable_widgets/text/placed_text_builder.dart'; import 'package:icarus/widgets/draggable_widgets/text/formatted_text_view.dart'; import 'package:icarus/widgets/draggable_widgets/text/text_widget.dart'; @@ -172,6 +176,72 @@ void main() { expect(container.read(textProvider).single.text, 'edited'); }); + testWidgets('semantics text changes enter the draft and commit pipeline', + (tester) async { + final semanticsHandle = tester.ensureSemantics(); + final container = createContainer(); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'before', + ]); + + await tester.pumpWidget(buildTextHarness(container)); + + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); + await tester.tap(find.byType(TextField)); + await tester.pump(); + final placedText = find.semantics.byLabel('Placed text'); + expect(placedText, findsOneWidget); + tester.semantics.performAction( + placedText, + SemanticsAction.setText, + args: 'edited through semantics', + ); + await tester.pump(); + + expect( + container.read(textDraftProvider), + {'text-1': 'edited through semantics'}, + ); + expect(container.read(textProvider).single.text, 'before'); + + final field = tester.widget(find.byType(TextField)); + field.focusNode!.unfocus(); + await tester.pump(); + + expect(container.read(textDraftProvider), isEmpty); + expect( + container.read(textProvider).single.text, + 'edited through semantics', + ); + semanticsHandle.dispose(); + }); + + testWidgets('controller text changes enter the draft pipeline', + (tester) async { + final container = createContainer(); + container.read(textProvider.notifier).fromHive([ + PlacedText(id: 'text-1', position: const Offset(10, 20))..text = 'before', + ]); + + await tester.pumpWidget(buildTextHarness(container)); + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + field.controller!.value = const TextEditingValue( + text: 'edited through controller', + selection: TextSelection.collapsed(offset: 25), + ); + await tester.pump(); + + expect( + container.read(textDraftProvider), + {'text-1': 'edited through controller'}, + ); + expect(container.read(textProvider).single.text, 'before'); + }); + testWidgets( 'drag start commits the draft before the drag lifecycle swaps children', (tester) async { @@ -273,17 +343,23 @@ void main() { container.read(textProvider.notifier).fromHive(page.textData); final strategyNotifier = container.read(strategyProvider.notifier); - strategyNotifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: page.id, - ), - ) - ..activePageID = page.id; + strategyNotifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategyPageSessionProvider.notifier).setStateForTest( + const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ), + ); await tester.pumpWidget(buildTextHarness(container)); await tester.tap(find.byType(FormattedTextView)); @@ -311,6 +387,15 @@ void main() { expect(container.read(textDraftProvider), {'text-1': 'before edited'}); expect(savedStrategy, isNotNull); expect(savedStrategy!.pages.single.textData.single.text, 'before edited'); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: SizedBox.shrink()), + ), + ); + await tester.pump(); + container.read(strategyProvider.notifier).cancelPendingSave(); }); testWidgets('feedback widget matches editable widget size', (tester) async { @@ -324,6 +409,95 @@ void main() { expect(feedbackSize.height, editableSize.height); }); + testWidgets('text widget starts single-line and grows instead of scrolling', + (tester) async { + final container = createContainer(); + container.read(textProvider.notifier).fromHive([ + PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 80, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'ew', + ]); + + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + await tester.tap(find.byType(FormattedTextView)); + await tester.pump(); + + final initialSize = tester.getSize(find.byType(TextWidget)); + expect(initialSize.height, lessThan(64)); + + await tester.enterText( + find.byType(TextField), + 'this text wraps onto a few lines', + ); + await tester.pump(); + + final wrappedSize = tester.getSize(find.byType(TextWidget)); + expect(wrappedSize.height, greaterThan(initialSize.height)); + + final scrollableFinder = find.descendant( + of: find.byType(TextField), + matching: find.byType(Scrollable), + ); + final scrollableState = + tester.state(scrollableFinder.first); + final scrollable = tester.widget(scrollableFinder.first); + expect(scrollable.axisDirection, AxisDirection.down); + expect( + scrollableState.position.maxScrollExtent, + 0, + reason: 'wrappedSize=$wrappedSize', + ); + }); + + testWidgets('side switch leaves multiline text stored canonically', + (tester) async { + final container = createContainer(); + final placedText = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'same text\nsecond line'; + + container.read(textProvider.notifier).fromHive([placedText]); + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + final renderedSize = tester.getSize(find.byType(TextWidget)); + container.read(mapProvider.notifier).switchSide(); + expect(renderedSize, isNot(Size.zero)); + expect(container.read(textProvider).single.position, placedText.position); + }); + + testWidgets( + 'single-line text remains canonical after deterministic measurement', + (tester) async { + final container = createContainer(); + final placedText = PlacedText( + id: 'text-1', + position: const Offset(10, 20), + size: 220, + fontSize: 16, + sizeVersion: worldSizedMediaVersion, + )..text = 'one line'; + + container.read(textProvider.notifier).fromHive([placedText]); + await tester.pumpWidget(buildTextHarness(container)); + await tester.pump(); + + final renderedSize = tester.getSize(find.byType(TextWidget)); + container.read(mapProvider.notifier).switchSide(); + expect(renderedSize, isNot(Size.zero)); + expect(container.read(textProvider).single.position, placedText.position); + }); + testWidgets('screenshot text stays read-only across page updates', (tester) async { final container = createContainer(); diff --git a/test/unsaved_strategy_guard_test.dart b/test/unsaved_strategy_guard_test.dart index 5cf242dc..f77e8b51 100644 --- a/test/unsaved_strategy_guard_test.dart +++ b/test/unsaved_strategy_guard_test.dart @@ -4,21 +4,31 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; import 'package:icarus/const/app_provider_container.dart'; import 'package:icarus/const/coordinate_system.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/maps.dart'; import 'package:icarus/const/placed_classes.dart'; import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; import 'package:icarus/providers/folder_provider.dart'; import 'package:icarus/providers/in_app_debug_provider.dart'; import 'package:icarus/providers/user_preferences_provider.dart'; import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/providers/text_draft_provider.dart'; import 'package:icarus/providers/text_provider.dart'; import 'package:icarus/services/unsaved_strategy_guard.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; bool _adaptersRegistered = false; @@ -53,7 +63,7 @@ class _FakeGuardStrategyProvider extends StrategyProvider { @override Future forceSaveNow(String id) async { forceSaveCalls++; - state = state.copyWith(isSaved: true); + ref.read(strategySaveStateProvider.notifier).markPersisted(); } @override @@ -76,6 +86,68 @@ class _ThrowingSaveStrategyProvider extends StrategyProvider { } } +class _GuardAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _GuardOpQueue extends StrategyOpQueueNotifier { + _GuardOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; + + StrategyOpQueueState get currentState => state; + + void failDurability() { + state = state.copyWith(hasDurabilityFailure: true); + } + + void settle() { + state = StrategyOpQueueState( + accountId: initialState.accountId, + strategyPublicId: initialState.strategyPublicId, + clientId: initialState.clientId, + durableLoaded: true, + ); + } +} + +class _GuardMediaQueue extends CloudMediaUploadQueueNotifier { + _GuardMediaQueue([ + this.initialState = const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ), + ]); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +const _guardEntityKey = EntitySyncKey.strategy(); +const _guardPendingIntent = QueuedEntityIntent( + entityKey: _guardEntityKey, + pending: PendingOp( + op: StrategyPatchOp( + opId: 'guard-op', + payload: {'name': 'pending'}, + expectedStrategyRevision: 1, + ), + clientId: 'guard-client', + ), +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); CoordinateSystem(playAreaSize: const Size(1920, 1080)); @@ -133,22 +205,21 @@ void main() { .setDraft('text-1', 'autosaved draft'); final notifier = container.read(strategyProvider.notifier); - notifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: strategy.pages.single.id, - ), - ) - ..activePageID = strategy.pages.single.id; + notifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategySaveStateProvider.notifier).markDirty(); final result = await notifier.flushPendingAutosaveBeforeExit(); expect(result, isTrue); - expect(container.read(strategyProvider).isSaved, isTrue); + expect(container.read(strategySaveStateProvider).isDirty, isFalse); final saved = Hive.box(HiveBoxNames.strategiesBox).get(strategy.id); expect(saved, isNotNull); @@ -171,29 +242,29 @@ void main() { .setDraft('text-1', 'unsaved draft'); final notifier = container.read(strategyProvider.notifier); - notifier - ..setFromState( - StrategyState( - isSaved: false, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: strategy.pages.single.id, - ), - ) - ..activePageID = strategy.pages.single.id; + notifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); + container.read(strategySaveStateProvider.notifier).markDirty(); final result = await notifier.flushPendingAutosaveBeforeExit(); expect(result, isFalse); - expect(container.read(strategyProvider).isSaved, isFalse); + expect(container.read(strategySaveStateProvider).isDirty, isTrue); final saved = Hive.box(HiveBoxNames.strategiesBox).get(strategy.id); expect(saved, isNotNull); expect(saved!.pages.single.textData.single.text, 'before'); }); - test('already saved returns true without another save', () async { + test('an active draft is saved even when committed state was clean', + () async { await _setAutosaveEnabled(true); final strategy = await _storeStrategyWithText( id: 'strategy-3', @@ -206,20 +277,18 @@ void main() { .fromHive(strategy.pages.single.textData); container .read(textDraftProvider.notifier) - .setDraft('text-1', 'draft should not save'); + .setDraft('text-1', 'active draft'); final notifier = container.read(strategyProvider.notifier); - notifier - ..setFromState( - StrategyState( - isSaved: true, - stratName: strategy.name, - id: strategy.id, - storageDirectory: null, - activePageId: strategy.pages.single.id, - ), - ) - ..activePageID = strategy.pages.single.id; + notifier.setFromState( + StrategyState( + strategyId: strategy.id, + strategyName: strategy.name, + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + ); final result = await notifier.flushPendingAutosaveBeforeExit(); @@ -227,7 +296,7 @@ void main() { final saved = Hive.box(HiveBoxNames.strategiesBox).get(strategy.id); expect(saved, isNotNull); - expect(saved!.pages.single.textData.single.text, 'before'); + expect(saved!.pages.single.textData.single.text, 'active draft'); }); test('no loaded strategy returns true', () async { @@ -245,18 +314,19 @@ void main() { overrides: [ strategyProvider.overrideWith( () => _ThrowingSaveStrategyProvider( - StrategyState( - isSaved: false, - stratName: 'Strategy 4', - id: 'strategy-4', + const StrategyState( + strategyId: 'strategy-4', + strategyName: 'Strategy 4', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ), ), ), ], ); addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).markDirty(); await expectLater( container @@ -301,12 +371,12 @@ void main() { 'dirty autosave-enabled exit saves and continues without dialog', (tester) async { notifier = _FakeGuardStrategyProvider( - initialState: StrategyState( - isSaved: false, - stratName: 'Strategy A', - id: 'strategy-a', + initialState: const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ), flushResult: true, ); @@ -316,6 +386,7 @@ void main() { ], ); addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).markDirty(); await pumpHarness(tester); var continueCalls = 0; @@ -335,16 +406,56 @@ void main() { expect(find.text('Save changes?'), findsNothing); }); + testWidgets('clean local state still flushes an active text draft', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'strategy-draft', + strategyName: 'Draft Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ), + flushResult: true, + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + ], + ); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('text-1', 'active local draft'); + await pumpHarness(tester); + + var continueCalls = 0; + final result = await guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-local-draft', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(continueCalls, 1); + expect(notifier.flushCalls, 1); + expect(find.text('Save changes?'), findsNothing); + }); + testWidgets( 'dirty autosave-disabled exit shows dialog and save still works', (tester) async { notifier = _FakeGuardStrategyProvider( - initialState: StrategyState( - isSaved: false, - stratName: 'Strategy B', - id: 'strategy-b', + initialState: const StrategyState( + strategyId: 'strategy-b', + strategyName: 'Strategy B', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ), flushResult: false, ); @@ -354,6 +465,7 @@ void main() { ], ); addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).markDirty(); await pumpHarness(tester); var continueCalls = 0; @@ -382,12 +494,12 @@ void main() { 'dirty autosave-disabled exit keeps dont-save branch behavior intact', (tester) async { notifier = _FakeGuardStrategyProvider( - initialState: StrategyState( - isSaved: false, - stratName: 'Strategy C', - id: 'strategy-c', + initialState: const StrategyState( + strategyId: 'strategy-c', + strategyName: 'Strategy C', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ), flushResult: false, ); @@ -397,6 +509,7 @@ void main() { ], ); addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).markDirty(); await pumpHarness(tester); var continueCalls = 0; @@ -424,12 +537,12 @@ void main() { testWidgets('autosave flush failure reports error and blocks exit', (tester) async { notifier = _FakeGuardStrategyProvider( - initialState: StrategyState( - isSaved: false, - stratName: 'Strategy D', - id: 'strategy-d', + initialState: const StrategyState( + strategyId: 'strategy-d', + strategyName: 'Strategy D', + source: StrategySource.local, storageDirectory: null, - activePageId: 'page-1', + isOpen: true, ), flushResult: true, flushError: StateError('boom'), @@ -440,6 +553,7 @@ void main() { ], ); addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).markDirty(); await pumpHarness(tester); var continueCalls = 0; @@ -461,6 +575,575 @@ void main() { expect(logs.last.message, 'Failed to save strategy before leaving.'); expect(logs.last.source, 'guard-test-error'); }); + + testWidgets('refused work can leave without a promise to retry it', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + attentionByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: lineupPageMismatchMessage, + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-refused', + onContinue: () async {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsOneWidget); + expect(find.textContaining('another page'), findsOneWidget); + expect(find.textContaining('from the sync button'), findsOneWidget); + expect(find.textContaining('retry it later'), findsNothing); + await tester.tap(find.text('Leave anyway')); + await tester.pumpAndSettle(); + expect(await guardFuture, isTrue); + }); + + testWidgets('offline durable work can leave and remains queued', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: 'Cloud connection is offline.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-offline', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsOneWidget); + expect( + find.textContaining('have not reached the cloud'), + findsOneWidget, + ); + expect(opQueue.currentState.pending, hasLength(1)); + await tester.tap(find.text('Leave anyway')); + await tester.pumpAndSettle(); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(opQueue.currentState.pending, hasLength(1)); + }); + + testWidgets('leave anyway checks again what changed while it was asked', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: 'Cloud connection is offline.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-leave-recheck', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + expect(find.text('Leave anyway'), findsOneWidget); + + // The outbox stops being trustworthy while the dialog is open. + opQueue.failDurability(); + await tester.tap(find.text('Leave anyway')); + await tester.pumpAndSettle(); + + expect(continueCalls, 0); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); + await tester.pumpAndSettle(); + expect(await guardFuture, isFalse); + }); + + testWidgets('paused viewer work has a leave-anyway path', (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + pausedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + lastError: 'This viewer edit cannot be retried automatically.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-viewer', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsOneWidget); + await tester.tap(find.text('Leave anyway')); + await tester.pumpAndSettle(); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(opQueue.currentState.pausedByEntityKey, isNotEmpty); + }); + + testWidgets('non-durable cloud work cannot leave', (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + hasDurabilityFailure: true, + lastError: 'Cloud work could not be saved to the durable outbox.', + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-nondurable', + onContinue: () async {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsNothing); + expect(find.text('Stay here'), findsOneWidget); + await tester.tap(find.text('Stay here')); + await tester.pumpAndSettle(); + expect(await guardFuture, isFalse); + }); + + for (final failedStore in ['ops', 'media']) { + testWidgets('unreadable records cannot hide a $failedStore write failure', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + hasDurabilityFailure: failedStore == 'ops', + ), + )), + cloudMediaUploadQueueProvider.overrideWith(() => _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: const [], + isProcessing: false, + durabilityError: + failedStore == 'media' ? 'Disk write failed.' : null, + loadIssues: const [ + DurableCloudMediaOutboxLoadIssue( + storageKey: 'old-unreadable', + error: 'bad record', + ) + ], + ), + )), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ]); + addTearDown(container.dispose); + await pumpHarness(tester); + var continued = false; + final guarded = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'mixed-durability-test', + onContinue: () async { + continued = true; + }, + ); + await tester.pumpAndSettle(); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); + await tester.pumpAndSettle(); + expect(await guarded, isFalse); + expect(continued, isFalse); + }); + } + + testWidgets('an unreliable media outbox can leave without deleting work', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final mediaQueue = _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'image-a', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'image-a', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ], + isProcessing: false, + loadIssues: const [ + DurableCloudMediaOutboxLoadIssue( + storageKey: 'account-a|unreadable-image', + error: 'bad record', + ), + ], + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-unreliable-media', + onContinue: () async { + continueCalls += 1; + }, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsOneWidget); + expect(find.textContaining('will not delete'), findsOneWidget); + await tester.tap(find.text('Leave anyway')); + await tester.pumpAndSettle(); + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + }); + + testWidgets('media without a durable strategy reference cannot leave', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final mediaQueue = _GuardMediaQueue( + CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'staged-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'staged-image', + fileExtension: '.png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 3), + ), + ], + isProcessing: false, + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(() => mediaQueue), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-staged-media', + onContinue: () async {}, + ); + await tester.pumpAndSettle(); + + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); + await tester.pumpAndSettle(); + expect(await guardFuture, isFalse); + }); + + testWidgets('cloud exit stages an active text draft before leaving', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith( + () => _GuardOpQueue( + const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + durableLoaded: true, + ), + ), + ), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(false), + ], + ); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('text-a', 'active cloud draft'); + await pumpHarness(tester); + + var continueCalls = 0; + final result = await guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-draft', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pumpAndSettle(); + + expect(result, isTrue); + expect(continueCalls, 1); + expect(notifier.forceSaveCalls, 1); + expect(container.read(textDraftProvider), isEmpty); + }); + + testWidgets('active cloud flush completes and exits without a dialog', + (tester) async { + notifier = _FakeGuardStrategyProvider( + initialState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + isOpen: true, + ), + flushResult: true, + ); + final opQueue = _GuardOpQueue( + StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'guard-client', + queuedByEntityKey: {_guardEntityKey: _guardPendingIntent}, + durableLoaded: true, + isFlushing: true, + ), + ); + container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(() => notifier), + strategyOpQueueProvider.overrideWith(() => opQueue), + cloudMediaUploadQueueProvider.overrideWith(_GuardMediaQueue.new), + authProvider.overrideWith(_GuardAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + ], + ); + addTearDown(container.dispose); + await pumpHarness(tester); + + var continueCalls = 0; + final guardFuture = guardUnsavedStrategyExit( + context: context, + ref: ref, + source: 'guard-test-cloud-flush', + onContinue: () async { + continueCalls++; + }, + ); + await tester.pump(); + opQueue.settle(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(await guardFuture, isTrue); + expect(continueCalls, 1); + expect(find.text('Cloud sync pending'), findsNothing); + }); }); } diff --git a/test/update_checker_test.dart b/test/update_checker_test.dart index 49b5ce75..edcc68d8 100644 --- a/test/update_checker_test.dart +++ b/test/update_checker_test.dart @@ -138,7 +138,7 @@ void main() { }; final result = await UpdateChecker.checkForUpdateSignal( - isWebOverride: true, + isWebOverride: false, isWindowsOverride: false, ); @@ -147,6 +147,27 @@ void main() { expect(result.isUpdateAvailable, isFalse); }); + test('web never fetches the desktop version file', () async { + var fetches = 0; + UpdateChecker.fetchVersionInfoOverride = () async { + fetches += 1; + return {'current_version_number': '999'}; + }; + + final update = await UpdateChecker.checkForUpdateSignal( + isWebOverride: true, + ); + final storeUpdate = await UpdateChecker.checkForWindowsStoreUpdateSignal( + isWebOverride: true, + ); + + expect(fetches, 0); + for (final result in [update, storeUpdate]) { + expect(result.isSupported, isFalse); + expect(result.isUpdateAvailable, isFalse); + } + }); + test('provider exposes update result from checker service', () async { UpdateChecker.windowsStoreCheckOverride = () async { return { diff --git a/test/video_export_navigation_failure_test.dart b/test/video_export_navigation_failure_test.dart index 2a207800..d0184143 100644 --- a/test/video_export_navigation_failure_test.dart +++ b/test/video_export_navigation_failure_test.dart @@ -9,6 +9,7 @@ import 'package:icarus/providers/strategy_page.dart'; import 'package:icarus/providers/strategy_provider.dart'; import 'package:icarus/providers/strategy_settings_provider.dart'; import 'package:icarus/services/video_export/ffmpeg_video_encoder.dart'; +import 'package:icarus/services/video_export/ffmpeg_video_sink.dart'; import 'package:icarus/services/video_export/video_export_quality.dart'; import 'package:icarus/services/video_export/video_exporter.dart'; @@ -66,8 +67,11 @@ void main() { exporter.export( pages: pages, stepDuration: const Duration(seconds: 1), - ffmpegBinary: 'must-not-run', - outputPath: 'must-not-create.mp4', + sink: FfmpegVideoSink( + binary: 'must-not-run', + outputPath: 'must-not-create.mp4', + quality: VideoExportQuality.social, + ), quality: VideoExportQuality.social, onProgress: (_, label) { startedPreparing = true; diff --git a/test/video_export_source_test.dart b/test/video_export_source_test.dart new file mode 100644 index 00000000..9d6e5412 --- /dev/null +++ b/test/video_export_source_test.dart @@ -0,0 +1,546 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/share_link_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/services/video_export/video_export_errors.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/services/video_export/video_export_source.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as path; + +const _strategyId = 'strategy-1'; +const _imageUrl = 'https://media.example.com/image-1.png'; + +final _png = Uint8List.fromList( + img.encodePng( + img.fill(img.Image(width: 4, height: 4), color: img.ColorRgb8(1, 2, 3)), + ), +); + +PlacedImage _image(String id) => PlacedImage( + position: const Offset(500, 500), + id: id, + aspectRatio: 1, + scale: 100, + fileExtension: '.png', + ); + +class _OpenStrategy extends StrategyProvider { + _OpenStrategy(this.source, {this.storageDirectory}); + + final StrategySource source; + final String? storageDirectory; + int saves = 0; + + @override + StrategyState build() => StrategyState( + strategyId: _strategyId, + strategyName: 'Strategy', + source: source, + storageDirectory: storageDirectory, + isOpen: true, + ); + + @override + Future forceSaveNow(String id) async => saves++; +} + +/// This device's op queue, set by the test. +class _Queue extends StrategyOpQueueNotifier { + _Queue(this.initial); + + final StrategyOpQueueState initial; + var flushes = 0; + + @override + StrategyOpQueueState build() => initial; + + @override + Future flushNow() async => flushes++; + + /// Everything this device queued has landed. + void settle() => state = const StrategyOpQueueState(durableLoaded: true); +} + +class _NoUploads extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => + const CloudMediaUploadQueueState(jobs: [], isProcessing: false); +} + +class _Repository extends Fake implements ConvexStrategyRepository { + int fetches = 0; + final shareTokens = []; + + @override + Future fetchFullSnapshot( + String strategyPublicId, { + String? shareToken, + }) async { + fetches++; + shareTokens.add(shareToken); + final now = DateTime.utc(2026); + RemotePage page(String id, int sortIndex) => RemotePage( + publicId: id, + strategyPublicId: _strategyId, + name: 'Page $id', + sortIndex: sortIndex, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ); + RemoteFullPage fullPage(String id, int sortIndex) => RemoteFullPage( + page: page(id, sortIndex), + content: + RemotePageContent(revision: 1, createdAt: now, updatedAt: now), + ); + return RemoteFullStrategySnapshot( + header: RemoteStrategyHeader( + publicId: _strategyId, + name: 'Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + ), + pages: [fullPage('b', 1), fullPage('a', 0)], + elementsByPage: { + for (final (pageId, imageId) in [('a', 'image-a'), ('b', 'image-b')]) + pageId: [ + RemoteElement( + publicId: imageId, + strategyPublicId: _strategyId, + pagePublicId: pageId, + elementType: 'image', + payload: cloudElementPayload( + kind: 'image', + data: cloudImagePayloadFromPlacedImage(_image(imageId)), + ), + sortIndex: 0, + revision: 1, + deleted: false, + ), + ], + }, + lineupsByPage: const {}, + assetsById: { + for (final imageId in ['image-a', 'image-b']) + imageId: RemoteImageAsset( + publicId: imageId, + fileExtension: '.png', + width: 4, + height: 4, + url: '$_imageUrl?$imageId', + legacyStoragePath: null, + uploadStatus: 'active', + ), + }, + ); + } +} + +Future _pumpRef( + WidgetTester tester, List overrides) async { + late WidgetRef ref; + await tester.pumpWidget(ProviderScope( + overrides: overrides, + child: Consumer(builder: (context, widgetRef, _) { + ref = widgetRef; + return const SizedBox.shrink(); + }), + )); + return ref; +} + +void main() { + group('a cloud strategy', () { + List cloud( + _Queue queue, + _Repository repository, { + List more = const [], + }) => + [ + strategyProvider + .overrideWith(() => _OpenStrategy(StrategySource.cloud)), + strategyOpQueueProvider.overrideWith(() => queue), + cloudMediaUploadQueueProvider.overrideWith(_NoUploads.new), + convexStrategyRepositoryProvider.overrideWithValue(repository), + ...more, + ]; + + testWidgets('waits for queued work, then reads the whole strategy', + (tester) async { + final queue = _Queue(const StrategyOpQueueState( + durableLoaded: true, + isFlushing: true, + )); + final repository = _Repository(); + final ref = await _pumpRef(tester, cloud(queue, repository)); + final requested = []; + + final source = await tester.runAsync( + () => http.runWithClient( + () async { + final loading = loadVideoExportSource(ref, pageIds: {'a'}); + await Future.delayed(const Duration(milliseconds: 250)); + // The edits were pushed, but nothing is read until they land. + expect(queue.flushes, 1); + expect(repository.fetches, 0); + queue.settle(); + return loading; + }, + () => MockClient((request) async { + requested.add(request.url.query); + return http.Response.bytes(_png, 200); + }), + ), + ); + addTearDown(source!.images.release); + + expect(repository.fetches, 1); + expect(source.strategy.id, _strategyId); + expect(source.strategy.mapData, MapValue.ascent); + expect( + [for (final page in source.strategy.pages) page.id], + containsAll(['a', 'b']), + ); + // Only the exported page's image is fetched and decoded. + expect(requested, ['image-a']); + expect(source.images.sources.keys, ['image-a']); + expect(source.images.sources['image-a'], isA()); + }); + + testWidgets( + 'with nothing to send, reads at once and leaves the save chip alone', + (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState(durableLoaded: true)), + repository, + ), + ); + + final source = await tester.runAsync( + () => http.runWithClient( + () => loadVideoExportSource(ref, pageIds: {'a'}), + () => MockClient((_) async => http.Response.bytes(_png, 200)), + ), + ); + addTearDown(source!.images.release); + + expect(repository.fetches, 1); + // A save that sends nothing must not leave the chip saying syncing. + final saveState = ref.read(strategySaveStateProvider); + expect(saveState.hasPendingCloudSync, isFalse); + expect(saveState.isDirty, isFalse); + }); + + testWidgets('stops when this device has not synced in time', + (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState( + durableLoaded: true, + isFlushing: true, + )), + repository, + ), + ); + + Object? error; + loadVideoExportSource(ref, pageIds: {'a'}).then( + (_) {}, + onError: (Object caught) => error = caught, + ); + await tester.pump(videoExportSyncTimeout + const Duration(seconds: 1)); + + expect(error, isA()); + expect(repository.fetches, 0); + }); + + testWidgets('stops at once when work waits for review', (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState( + durableLoaded: true, + hasDurabilityFailure: true, + )), + repository, + ), + ); + + Object? error; + loadVideoExportSource(ref, pageIds: {'a'}).then( + (_) {}, + onError: (Object caught) => error = caught, + ); + await tester.pump(const Duration(milliseconds: 1)); + + expect(error, isA()); + }); + + testWidgets('stops waiting when the export is cancelled', (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState( + durableLoaded: true, + isFlushing: true, + )), + repository, + ), + ); + var cancelled = false; + + Object? error; + loadVideoExportSource( + ref, + pageIds: {'a'}, + isCancelled: () => cancelled, + ).then((_) {}, onError: (Object caught) => error = caught); + await tester.pump(const Duration(seconds: 1)); + cancelled = true; + await tester.pump(const Duration(milliseconds: 200)); + + expect(error, isA()); + expect(repository.fetches, 0); + }); + + testWidgets('stops between images when the export is cancelled', + (tester) async { + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState(durableLoaded: true)), + _Repository(), + ), + ); + var cancelled = false; + final requested = []; + + Object? error; + await tester.runAsync(() async { + try { + await http.runWithClient( + () => loadVideoExportSource( + ref, + pageIds: {'a', 'b'}, + isCancelled: () => cancelled, + ), + () => MockClient((request) async { + requested.add(request.url.query); + // The user cancels while the first image downloads. + cancelled = true; + return http.Response.bytes(_png, 200); + }), + ); + } catch (caught) { + error = caught; + } + }); + + // Downloads start together; the cancel stops the export before the + // next image is decoded and held. + expect(error, isA()); + expect(requested, isNotEmpty); + }); + + testWidgets('refuses a selection naming a page that no longer exists', + (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState(durableLoaded: true)), + repository, + ), + ); + + Object? error; + await tester.runAsync(() async { + try { + await loadVideoExportSource(ref, pageIds: {'a', 'deleted'}); + } catch (caught) { + error = caught; + } + }); + + expect(error, isA()); + }); + + testWidgets('reads through the link a signed-out reader opened', + (tester) async { + final repository = _Repository(); + final ref = await _pumpRef( + tester, + cloud( + _Queue(const StrategyOpQueueState(durableLoaded: true)), + repository, + more: [ + shareLinkViewProvider.overrideWith( + (ref) => (strategyPublicId: _strategyId, token: 'link-token'), + ), + ], + ), + ); + + final source = await tester.runAsync( + () => http.runWithClient( + () => loadVideoExportSource(ref, pageIds: {'a'}), + () => MockClient((_) async => http.Response.bytes(_png, 200)), + ), + ); + addTearDown(source!.images.release); + + expect(repository.shareTokens, ['link-token']); + }); + + testWidgets('offers the pages the editor lists, in order', (tester) async { + final ref = await _pumpRef(tester, [ + strategyProvider + .overrideWith(() => _OpenStrategy(StrategySource.cloud)), + remoteEditorSnapshotProvider.overrideWith(_TwoPageSnapshot.new), + ]); + await tester.runAsync( + () => ProviderScope.containerOf(tester.element(find.byType(Consumer))) + .read(remoteEditorSnapshotProvider.future), + ); + + expect(videoExportPageChoices(ref), [ + (id: 'first', name: 'First'), + (id: 'second', name: 'Second'), + ]); + }); + }); + + group('a local strategy', () { + late Directory dir; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('icarus_video_source'); + Hive.init(dir.path); + registerIcarusAdapters(Hive); + await Hive.openBox(HiveBoxNames.strategiesBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + testWidgets('is read back from the library with its image files', + (tester) async { + final storage = path.join(dir.path, 'storage'); + await tester.runAsync(() async { + final file = File(path.join(storage, 'images', 'image-a.png')); + await file.parent.create(recursive: true); + await file.writeAsBytes(_png); + await Hive.box(HiveBoxNames.strategiesBox).put( + _strategyId, + StrategyData( + id: _strategyId, + name: 'Strategy', + mapData: MapValue.bind, + versionNumber: 1, + lastEdited: DateTime(2026), + folderID: null, + pages: [ + StrategyPage( + id: 'a', + name: 'A', + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: [_image('image-a'), _image('image-missing')], + utilityData: const [], + sortIndex: 0, + isAttack: true, + settings: StrategySettings(), + ), + ], + ), + ); + }); + final strategy = _OpenStrategy( + StrategySource.local, + storageDirectory: storage, + ); + final ref = await _pumpRef(tester, [ + strategyProvider.overrideWith(() => strategy), + ]); + + final source = await tester.runAsync( + () => loadVideoExportSource(ref, pageIds: {'a'}), + ); + addTearDown(source!.images.release); + + expect(strategy.saves, 1); + expect(source.strategy.mapData, MapValue.bind); + expect(source.images.sources['image-a'], isA()); + // A file the editor cannot find is exported as it shows: unavailable. + expect(source.images.sources['image-missing'], isA()); + }); + }); +} + +class _TwoPageSnapshot extends RemoteEditorSnapshotNotifier { + @override + Future build() async { + final now = DateTime.utc(2026); + RemotePage page(String id, String name, int sortIndex) => RemotePage( + publicId: id, + strategyPublicId: _strategyId, + name: name, + sortIndex: sortIndex, + isAttack: true, + revision: 1, + createdAt: now, + updatedAt: now, + ); + return RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: _strategyId, + name: 'Strategy', + mapData: Maps.mapNames[MapValue.ascent]!, + revision: 1, + createdAt: now, + updatedAt: now, + role: 'owner', + ), + pages: [page('second', 'Second', 1), page('first', 'First', 0)], + ), + activePage: null, + ); + } +} diff --git a/test/video_exporter_sink_test.dart b/test/video_exporter_sink_test.dart new file mode 100644 index 00000000..6f42c06d --- /dev/null +++ b/test/video_exporter_sink_test.dart @@ -0,0 +1,359 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/image_scale_policy.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/map_provider.dart'; +import 'package:icarus/providers/strategy_image_source.dart'; +import 'package:icarus/providers/strategy_page.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_settings_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/services/video_export/ffmpeg_video_sink.dart'; +import 'package:icarus/services/video_export/video_export_quality.dart'; +import 'package:icarus/services/video_export/video_exporter.dart'; +import 'package:icarus/services/video_export/video_frame_sink.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:image/image.dart' as img; +import 'package:supabase_flutter/supabase_flutter.dart' show AuthState, Session; + +/// Pure magenta, a colour no map or marker draws. +final _magentaPng = Uint8List.fromList( + img.encodePng( + img.fill( + img.Image(width: 64, height: 36), + color: img.ColorRgb8(255, 0, 255), + ), + ), +); + +/// Records every call an export makes to the app's one Convex client. +class _RecordingConvexAuth extends Fake implements AuthProviderConvexApi { + final calls = []; + + @override + Stream get authState => const Stream.empty(); + + @override + bool get isAuthenticated => true; + + @override + Future setAuthWithRefresh({ + required Future Function() fetchToken, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + calls.add('setAuthWithRefresh'); + throw UnimplementedError(); + } + + @override + Future clearAuth() async => calls.add('clearAuth'); +} + +class _NoSupabase extends Fake implements AuthProviderSupabaseApi { + @override + Session? get currentSession => null; + + @override + Stream get onAuthStateChange => const Stream.empty(); +} + +class _RecordingSink implements VideoFrameSink { + final video = Uint8List.fromList([7, 7, 7]); + final frames = <(Uint8List, double)>[]; + ({int width, int height, int totalFrames, double totalSeconds})? started; + var closed = 0; + + @override + Future start({ + required int width, + required int height, + required int totalFrames, + required double totalSeconds, + }) async { + started = ( + width: width, + height: height, + totalFrames: totalFrames, + totalSeconds: totalSeconds, + ); + } + + @override + Future addFrame(Uint8List rgba, double durationSeconds) async => + frames.add((rgba, durationSeconds)); + + @override + Future finish({void Function(double)? onProgress}) async => video; + + @override + void cancel() {} + + @override + Future close() async => closed++; +} + +StrategyPage _page(String id, int sortIndex, Offset imageAt) => StrategyPage( + id: id, + name: id, + drawingData: const [], + agentData: const [], + abilityData: const [], + textData: const [], + imageData: [ + PlacedImage( + position: imageAt, + id: 'image-1', + aspectRatio: 64 / 36, + scale: ImageScalePolicy.defaultWidth, + fileExtension: '.png', + sizeVersion: PlacedImage.currentSizeVersion, + ), + ], + utilityData: const [], + sortIndex: sortIndex, + isAttack: true, + settings: StrategySettings(), + ); + +VideoExporter _exporter(List pages) { + return VideoExporter( + strategy: StrategyData( + id: 'cloud-strategy', + name: 'Cloud strat', + mapData: MapValue.ascent, + versionNumber: 1, + lastEdited: DateTime(2026), + folderID: null, + pages: pages, + ), + strategyState: const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud strat', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ), + mapState: MapState(currentMap: MapValue.ascent, isAttack: true), + geometry: null, + imageSources: {'image-1': ImageBytes(_magentaPng)}, + ); +} + +/// Runs the export on real time with the app's frames still coming, as in +/// the app: the offscreen tree's layout builders wait for the app's next +/// frame. +Future _exportWithFrames( + WidgetTester tester, + VideoExporter exporter, + List pages, + VideoFrameSink sink, +) async { + Object? outcome; + var done = false; + await tester.runAsync(() async { + unawaited( + exporter + .export( + pages: pages, + stepDuration: const Duration(seconds: 2), + sink: sink, + quality: VideoExportQuality.social, + ) + .then((video) => outcome = video, + onError: (Object error) => outcome = error) + .whenComplete(() => done = true), + ); + }); + while (!done) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 16)), + ); + await tester.pump(); + } + return outcome; +} + +int _magentaPixels(Uint8List rgba) { + var count = 0; + for (var i = 0; i < rgba.length; i += 4) { + if (rgba[i] > 240 && rgba[i + 1] < 20 && rgba[i + 2] > 240) count++; + } + return count; +} + +/// A PATH ffmpeg, the development fallback of FfmpegVideoEncoder, with the +/// H.264 encoder the export asks for on this platform. +final String? _ffmpeg = () { + final ffmpeg = _onPath('ffmpeg'); + if (ffmpeg == null) return null; + final encoders = Process.runSync(ffmpeg, ['-hide_banner', '-encoders']); + final h264 = Platform.isWindows ? 'h264_mf' : 'libx264'; + return '${encoders.stdout}'.contains(h264) ? ffmpeg : null; +}(); +final String? _ffprobe = _onPath('ffprobe'); + +String? _onPath(String tool) { + try { + final result = Process.runSync( + Platform.isWindows ? 'where' : 'which', + [tool], + ); + if (result.exitCode != 0) return null; + return const LineSplitter().convert(result.stdout as String).first.trim(); + } on ProcessException { + return null; + } +} + +void main() { + late Directory hiveDir; + + setUpAll(() async { + hiveDir = await Directory.systemTemp.createTemp('icarus_video_sink'); + Hive.init(hiveDir.path); + registerIcarusAdapters(Hive); + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await MapThemeProfilesProvider.bootstrap(); + }); + + tearDownAll(() async { + await Hive.close(); + await hiveDir.delete(recursive: true); + }); + + testWidgets( + 'renders every page and transition frame into the sink, images and all', + (tester) async { + CoordinateSystem(playAreaSize: const Size(1600, 900)); + final pages = [ + _page('one', 0, const Offset(400, 400)), + _page('two', 1, const Offset(700, 600)), + ]; + final exporter = _exporter(pages); + final sink = _RecordingSink(); + + final outcome = await _exportWithFrames(tester, exporter, pages, sink); + + expect(outcome, same(sink.video)); + final transitionFrames = VideoExporter.transitionFrameCountFor(30); + expect(sink.started, ( + width: 1920, + height: 1080, + totalFrames: 2 + transitionFrames, + totalSeconds: VideoExporter.plannedDurationSeconds( + pageCount: 2, + stepSeconds: 2, + fps: 30, + ), + )); + expect(sink.frames, hasLength(2 + transitionFrames)); + expect(sink.frames.first.$2, 2); + expect(sink.frames.last.$2, 2); + for (final (_, duration) in sink.frames.skip(1).take(transitionFrames)) { + expect(duration, closeTo(1 / 30, 1e-9)); + } + for (final (rgba, _) in sink.frames) { + expect(rgba.length, 1920 * 1080 * 4); + } + expect(_magentaPixels(sink.frames.first.$1), greaterThan(500)); + expect(_magentaPixels(sink.frames.last.$1), greaterThan(500)); + expect(sink.closed, 1); + expect(CoordinateSystem.instance.isScreenshot, isFalse); + }); + + testWidgets('an export never touches the app\'s cloud session', + (tester) async { + CoordinateSystem(playAreaSize: const Size(1600, 900)); + final convex = _RecordingConvexAuth(); + AuthProvider.debugConvexApi = convex; + AuthProvider.debugSupabaseApi = _NoSupabase(); + addTearDown(AuthProvider.resetTestOverrides); + final pages = [ + _page('one', 0, const Offset(400, 400)), + _page('two', 1, const Offset(700, 600)), + ]; + + final outcome = await _exportWithFrames( + tester, + _exporter(pages), + pages, + _RecordingSink(), + ); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + + expect(outcome, isA()); + expect(convex.calls, isEmpty); + }); + + testWidgets('desktop encodes the frames into a playable .mp4 with ffmpeg', + (tester) async { + CoordinateSystem(playAreaSize: const Size(1600, 900)); + final pages = [ + _page('one', 0, const Offset(400, 400)), + _page('two', 1, const Offset(700, 600)), + ]; + final outputDir = (await tester.runAsync( + () => Directory.systemTemp.createTemp('icarus_video_out'), + ))!; + addTearDown(() => outputDir.deleteSync(recursive: true)); + final outputPath = '${outputDir.path}${Platform.pathSeparator}out.mp4'; + + final outcome = await _exportWithFrames( + tester, + _exporter(pages), + pages, + FfmpegVideoSink( + binary: _ffmpeg!, + outputPath: outputPath, + quality: VideoExportQuality.social, + ), + ); + + expect(outcome, isNull); + final probe = (await tester.runAsync( + () => Process.run(_ffprobe!, [ + '-v', + 'error', + '-show_entries', + 'stream=codec_name,width,height:format=duration', + '-of', + 'default=noprint_wrappers=1', + outputPath, + ]), + ))!; + final report = probe.stdout as String; + expect(report, contains('codec_name=h264')); + expect(report, contains('width=1920')); + expect(report, contains('height=1080')); + final seconds = double.parse( + RegExp(r'duration=([\d.]+)').firstMatch(report)!.group(1)!, + ); + expect( + seconds, + closeTo( + VideoExporter.plannedDurationSeconds( + pageCount: 2, + stepSeconds: 2, + fps: 30, + ), + 0.1, + ), + ); + // Needs a PATH ffmpeg and ffprobe, as local development has. + }, skip: _ffmpeg == null || _ffprobe == null); +} diff --git a/test/web/web_codecs_video_encoder_test.dart b/test/web/web_codecs_video_encoder_test.dart new file mode 100644 index 00000000..16df4458 --- /dev/null +++ b/test/web/web_codecs_video_encoder_test.dart @@ -0,0 +1,290 @@ +@TestOn('browser') +library; + +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/services/video_export/video_export_errors.dart'; +import 'package:icarus/services/video_export/web_codecs_video_encoder.dart'; + +import '../mp4_box_test_support.dart'; + +// Run with: flutter test --platform chrome test/web/web_codecs_video_encoder_test.dart +// Needs a Chrome or Edge build with H.264 (Chromium builds lack it); point +// CHROME_EXECUTABLE at it when Chrome is not installed. + +void main() { + test('Chrome can encode H.264 at every export size', () async { + for (final (width, height, fps) in [ + (1920, 1080, 60), + (1920, 1080, 30), + (1280, 720, 30), + (640, 360, 30), + ]) { + expect( + await WebCodecsMp4Encoder.isSupported( + width: width, + height: height, + fps: fps, + ), + isTrue, + reason: '${width}x$height@$fps', + ); + } + expect( + await WebCodecsMp4Encoder.isSupported( + width: 1920, + height: 1080, + fps: 30, + bitrate: 2000000, + ), + isTrue, + ); + }); + + test('encodes held frames into an MP4 the browser plays back', () async { + final encoder = WebCodecsMp4Encoder(); + await encoder.start( + inputWidth: 640, + inputHeight: 360, + outputWidth: 640, + outputHeight: 360, + fps: 30, + ); + await encoder.addFrame( + _solid(640, 360, 255, 0, 0), + duration: const Duration(seconds: 1), + ); + await encoder.addFrame( + _solid(640, 360, 0, 255, 0), + duration: const Duration(milliseconds: 500), + ); + await encoder.addFrame( + _solid(640, 360, 0, 0, 255), + duration: const Duration(seconds: 1), + ); + final mp4 = await encoder.finish(); + + final boxes = Mp4Boxes(mp4); + expect(boxes.topLevelTypes, ['ftyp', 'moov', 'mdat']); + expect(boxes.sampleSizes(), hasLength(75)); + expect(boxes.mediaTimescale(), 30000); + expect(boxes.mediaDuration(), 75000); + expect(boxes.sampleDurations().toSet(), {1000}); + // Each held page opens on a keyframe. + expect(boxes.syncSamples() ?? [for (var i = 1; i <= 75; i++) i], + containsAll([1, 31, 46])); + expect(boxes.trackSize(), (640, 360)); + + final video = await _loadVideo(mp4); + expect(video.videoWidth, 640); + expect(video.videoHeight, 360); + expect(video.duration, closeTo(2.5, 0.05)); + expect(await _colourAt(video, 0.5), 'red'); + expect(await _colourAt(video, 1.25), 'green'); + expect(await _colourAt(video, 2.2), 'blue'); + }); + + test('scales 1280x720 frames down to a 640x360 video', () async { + final encoder = WebCodecsMp4Encoder(); + await encoder.start( + inputWidth: 1280, + inputHeight: 720, + outputWidth: 640, + outputHeight: 360, + fps: 30, + bitrate: 1000000, + ); + await encoder.addFrame( + _solid(1280, 720, 0, 255, 0), + duration: const Duration(milliseconds: 500), + ); + await encoder.addFrame( + _solid(1280, 720, 0, 0, 255), + duration: const Duration(milliseconds: 500), + ); + final mp4 = await encoder.finish(); + + final boxes = Mp4Boxes(mp4); + expect(boxes.sampleSizes(), hasLength(30)); + expect(boxes.trackSize(), (640, 360)); + + final video = await _loadVideo(mp4); + expect(video.videoWidth, 640); + expect(video.videoHeight, 360); + expect(video.duration, closeTo(1.0, 0.05)); + expect(await _colourAt(video, 0.25), 'green'); + expect(await _colourAt(video, 0.75), 'blue'); + }); + + test('carries fractional frame remainders forward', () async { + final encoder = WebCodecsMp4Encoder(); + await encoder.start( + inputWidth: 64, + inputHeight: 64, + outputWidth: 64, + outputHeight: 64, + fps: 30, + ); + // 50 ms is 1.5 frames at 30 fps: the runs are 2, 1, 2 frames so the + // total stays round(150 ms * 30) = 5 frames (rounded half up). + for (var i = 0; i < 3; i++) { + await encoder.addFrame( + _solid(64, 64, 80 * i, 0, 0), + duration: const Duration(milliseconds: 50), + ); + } + final boxes = Mp4Boxes(await encoder.finish()); + expect(boxes.sampleSizes(), hasLength(5)); + expect(boxes.mediaDuration(), 5000); + }); + + test('cancel is idempotent and later calls report the cancel', () async { + final encoder = WebCodecsMp4Encoder(); + await encoder.start( + inputWidth: 64, + inputHeight: 64, + outputWidth: 64, + outputHeight: 64, + fps: 30, + ); + encoder + ..cancel() + ..cancel(); + await expectLater( + encoder.addFrame(_solid(64, 64, 0, 0, 0), duration: Duration.zero), + throwsA(isA()), + ); + }); +} + +Uint8List _solid(int width, int height, int r, int g, int b) { + final bytes = Uint8List(width * height * 4); + for (var i = 0; i < bytes.length; i += 4) { + bytes[i] = r; + bytes[i + 1] = g; + bytes[i + 2] = b; + bytes[i + 3] = 255; + } + return bytes; +} + +Future<_VideoElement> _loadVideo(Uint8List mp4) async { + final url = _Url.createObjectURL( + _Blob([mp4.toJS].toJS, _BlobOptions(type: 'video/mp4')), + ); + addTearDown(() => _Url.revokeObjectURL(url)); + final video = _document.createElement('video') as _VideoElement + ..muted = true + ..preload = 'auto'; + final loaded = _nextEvent(video, 'loadeddata'); + video.src = url; + await loaded; + return video; +} + +/// Seeks [video] to [seconds] and names the dominant colour at its centre. +Future _colourAt(_VideoElement video, double seconds) async { + final seeked = _nextEvent(video, 'seeked'); + video.currentTime = seconds; + await seeked; + final canvas = _document.createElement('canvas') as _Canvas + ..width = video.videoWidth + ..height = video.videoHeight; + final context = canvas.getContext('2d'); + context.drawImage(video, 0, 0); + final pixel = context + .getImageData(video.videoWidth ~/ 2, video.videoHeight ~/ 2, 1, 1) + .data + .toDart; + final (r, g, b) = (pixel[0], pixel[1], pixel[2]); + if (r > 180 && g < 80 && b < 80) return 'red'; + if (g > 180 && r < 80 && b < 80) return 'green'; + if (b > 180 && r < 80 && g < 80) return 'blue'; + return 'rgb($r, $g, $b)'; +} + +/// Completes on [video]'s next [type] event, or fails on its error event. +Future _nextEvent(_VideoElement video, String type) { + final done = Completer(); + late JSFunction onEvent; + late JSFunction onError; + void detach() { + video + ..removeEventListener(type, onEvent) + ..removeEventListener('error', onError); + } + + onEvent = ((JSAny? _) { + detach(); + if (!done.isCompleted) done.complete(); + }).toJS; + onError = ((JSAny? _) { + detach(); + if (!done.isCompleted) { + done.completeError( + StateError('The video element failed: code ${video.error?.code}'), + ); + } + }).toJS; + video + ..addEventListener(type, onEvent) + ..addEventListener('error', onError); + return done.future.timeout(const Duration(seconds: 20)); +} + +@JS('document') +external _Document get _document; + +extension type _Document._(JSObject _) implements JSObject { + external JSObject createElement(String tag); +} + +extension type _VideoElement._(JSObject _) implements JSObject { + external set src(String value); + external set muted(bool value); + external set preload(String value); + external set currentTime(num value); + external int get videoWidth; + external int get videoHeight; + external double get duration; + external _MediaError? get error; + external void addEventListener(String type, JSFunction listener); + external void removeEventListener(String type, JSFunction listener); +} + +extension type _MediaError._(JSObject _) implements JSObject { + external int get code; +} + +extension type _Canvas._(JSObject _) implements JSObject { + external set width(int value); + external set height(int value); + external _Context2D getContext(String contextId); +} + +extension type _Context2D._(JSObject _) implements JSObject { + external void drawImage(JSObject image, num dx, num dy); + external _ImageData getImageData(int x, int y, int width, int height); +} + +extension type _ImageData._(JSObject _) implements JSObject { + external JSUint8ClampedArray get data; +} + +@JS('Blob') +extension type _Blob._(JSObject _) implements JSObject { + external factory _Blob(JSArray parts, _BlobOptions options); +} + +extension type _BlobOptions._(JSObject _) implements JSObject { + external factory _BlobOptions({String type}); +} + +@JS('URL') +extension type _Url._(JSObject _) implements JSObject { + external static String createObjectURL(_Blob blob); + external static void revokeObjectURL(String url); +} diff --git a/test/web_editor_reload_test.dart b/test/web_editor_reload_test.dart new file mode 100644 index 00000000..db5d407c --- /dev/null +++ b/test/web_editor_reload_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/routes.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/open_cloud_strategy_store.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/strategy_view.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:icarus/widgets/global_shortcuts.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + group('reloading on the editor route', () { + testWidgets('reopens the cloud strategy the tab had open', (tester) async { + final route = StrategyView.restoredRoute( + openCloudStrategyId: 'cloud-strategy', + allowsLocalLibrary: false, + )! as PageRouteBuilder; + + await tester.pumpWidget(const SizedBox()); + final editor = route.pageBuilder( + tester.element(find.byType(SizedBox)), + kAlwaysCompleteAnimation, + kAlwaysCompleteAnimation, + ) as StrategyView; + + expect(route.settings.name, Routes.strategyView); + expect(editor.initialStrategyId, 'cloud-strategy'); + expect(editor.initialStrategySource, StrategySource.cloud); + }); + + test('opens an empty editor only where a local library exists', () { + expect( + StrategyView.restoredRoute( + openCloudStrategyId: null, + allowsLocalLibrary: true, + ), + isNotNull, + ); + expect( + StrategyView.restoredRoute( + openCloudStrategyId: null, + allowsLocalLibrary: false, + ), + isNull, + ); + }); + + testWidgets('with nothing to reopen on the web beta, lands on the library', + (tester) async { + await tester.pumpWidget(MaterialApp( + initialRoute: Routes.strategyView, + home: const Text('library'), + onGenerateRoute: (settings) => settings.name == Routes.strategyView + ? StrategyView.restoredRoute( + openCloudStrategyId: null, + allowsLocalLibrary: PlatformPolicy.webBeta.allowsLocalLibrary, + ) + : null, + )); + // Debug builds report the ignored initial route; release builds do not. + final report = tester.takeException(); + expect('$report', contains('Could not navigate to initial route')); + await tester.pumpAndSettle(); + + expect(find.text('library'), findsOneWidget); + expect(find.byType(StrategyView), findsNothing); + }); + }); + + group('cloudWorkspaceOutcomeProvider', () { + bool? outcomeFor(AppAuthState auth) { + final container = ProviderContainer(overrides: [ + authProvider.overrideWith(() => _FixedAuth(auth)), + ]); + addTearDown(container.dispose); + return container.read(cloudWorkspaceOutcomeProvider); + } + + test('is true once the user and Convex are ready', () { + expect(outcomeFor(_auth(ConvexAuthStatus.ready, ready: true)), isTrue); + }); + + test('waits while sign-in or cloud setup is settling', () { + expect(outcomeFor(_auth(ConvexAuthStatus.configuring)), isNull); + expect( + outcomeFor(_auth(ConvexAuthStatus.signedOut, isLoading: true)), + isNull, + ); + }); + + test('is false when signed out or in an auth incident', () { + expect(outcomeFor(_auth(ConvexAuthStatus.signedOut)), isFalse); + expect(outcomeFor(_auth(ConvexAuthStatus.incident)), isFalse); + }); + }); + + test('the open cloud strategy is recorded while the editor is open', + () async { + final store = MemoryOpenCloudStrategyStore(); + final strategy = _SettableStrategy(); + final container = ProviderContainer(overrides: [ + openCloudStrategyStoreProvider.overrideWithValue(store), + strategyProvider.overrideWith(() => strategy), + ]); + addTearDown(container.dispose); + + final editor = + container.listen(openCloudStrategyRecorderProvider, (_, __) {}); + expect(store.read(), isNull); + + strategy.open('cloud-a', StrategySource.cloud); + expect(store.read(), 'cloud-a'); + + // Switching in place (quick switcher, back/forward) follows along. + strategy.open('cloud-b', StrategySource.cloud); + expect(store.read(), 'cloud-b'); + + strategy.open('local-a', StrategySource.local); + expect(store.read(), isNull); + + strategy.open('cloud-c', StrategySource.cloud); + editor.close(); + await Future.delayed(Duration.zero); + expect(store.read(), isNull, reason: 'leaving the editor clears it'); + }); + + testWidgets('the web beta toolbar never offers a local Save', (tester) async { + for (final (policy, expected) in [ + (PlatformPolicy.webBeta, findsNothing), + (PlatformPolicy.desktop, findsOneWidget), + ]) { + final container = ProviderContainer(overrides: [ + platformPolicyProvider.overrideWithValue(policy), + strategyProvider.overrideWith(_SettableStrategy.new), + ]); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: const ShadApp(home: Scaffold(body: EditorToolbar())), + )); + + expect(find.byType(AutoSaveButton), expected, reason: '$policy'); + await tester.pumpWidget(const SizedBox()); + container.dispose(); + } + }); + + testWidgets('the global shortcuts node is never part of focus traversal', + (tester) async { + // On web, the navigator's initial-route focus reaches the page before the + // first frame, and Flutter answers by sorting the view's focus nodes by + // position (flutter/flutter#191508). With the app-wide shortcuts node in + // that sort beside the navigator, its unlaid-out box was measured and + // threw "RenderBox was not laid out" on every page load. flutter_test + // cannot replay web's build-then-layout startup order, so this checks + // the property that keeps the node out of the sort. + await tester.pumpWidget(ProviderScope( + child: MaterialApp( + builder: (context, child) => GlobalShortcuts(child: child!), + home: Scaffold( + body: Row(children: [ + TextButton(onPressed: () {}, child: const Text('one')), + TextButton(onPressed: () {}, child: const Text('two')), + ]), + ), + ), + )); + + final shortcutsNode = FocusManager.instance.rootScope.descendants + .singleWhere((node) => node.debugLabel == 'global-shortcuts'); + final viewScope = FocusManager.instance.rootScope.descendants + .whereType() + .firstWhere((node) => node.debugLabel == 'View Scope'); + + expect(shortcutsNode.skipTraversal, isTrue); + expect(viewScope.traversalDescendants, isNot(contains(shortcutsNode))); + // It still holds focus, so app-wide shortcuts keep working. + expect(shortcutsNode.hasFocus, isTrue); + }); +} + +AppAuthState _auth( + ConvexAuthStatus status, { + bool ready = false, + bool isLoading = false, +}) { + return AppAuthState( + isLoading: isLoading, + isAuthenticated: status != ConvexAuthStatus.signedOut, + isConvexUserReady: ready, + convexAuthStatus: status, + user: null, + ); +} + +class _FixedAuth extends AuthProvider { + _FixedAuth(this.auth); + + final AppAuthState auth; + + @override + AppAuthState build() => auth; +} + +class _SettableStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState(); + + void open(String id, StrategySource source) { + state = StrategyState( + strategyId: id, + strategyName: id, + source: source, + isOpen: true, + ); + } +} diff --git a/test/web_media_bytes_upload_test.dart b/test/web_media_bytes_upload_test.dart new file mode 100644 index 00000000..978ef07e --- /dev/null +++ b/test/web_media_bytes_upload_test.dart @@ -0,0 +1,885 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_cloud_media_outbox.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/collab/pending_media_bytes_store.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/providers/collab/cloud_collab_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/media_bytes_source.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/image_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/create_lineup_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +// The web beta: no image files, so picked images wait as pending bytes. + +const _imageId = 'web-image'; +final _imageBytes = Uint8List.fromList([137, 80, 78, 71, 1, 2, 3, 4, 5]); + +PendingMediaKey _key( + String assetId, { + String accountId = 'account-a', + String strategyId = 'strategy-a', +}) => + ( + accountId: accountId, + strategyPublicId: strategyId, + assetPublicId: assetId, + ); + +PendingMediaRecord _record( + String assetId, { + String accountId = 'account-a', + Uint8List? bytes, + Duration age = Duration.zero, +}) => + PendingMediaRecord( + key: _key(assetId, accountId: accountId), + bytes: bytes ?? _imageBytes, + savedAt: DateTime.now().subtract(age), + ); + +CloudMediaUploadJob _job( + String assetId, { + String accountId = 'account-a', + CloudMediaJobState state = CloudMediaJobState.pendingUpload, +}) => + CloudMediaUploadJob( + jobId: assetId, + accountId: accountId, + strategyPublicId: 'strategy-a', + assetPublicId: assetId, + fileExtension: '.png', + mimeType: 'image/png', + state: state, + referenceDurable: true, + provider: state == CloudMediaJobState.pendingAttach ? 'r2' : null, + uploadId: state == CloudMediaJobState.pendingAttach ? 'upload-1' : null, + objectKey: + state == CloudMediaJobState.pendingAttach ? 'object/$assetId' : null, + attempts: 0, + updatedAt: DateTime.utc(2026, 9, 24), + ); + +class _Auth extends AuthProvider { + _Auth({required this.ready}); + final bool ready; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: ready, + isConvexUserReady: ready, + convexAuthStatus: + ready ? ConvexAuthStatus.ready : ConvexAuthStatus.signedOut, + user: null, + ); +} + +class _CloudMode extends CloudCollabModeNotifier { + @override + CloudCollabModeState build() => const CloudCollabModeState( + featureFlagEnabled: true, + forceLocalFallback: false, + ); +} + +class _CloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.cloud, + isOpen: true, + ); +} + +class _OpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + clientId: 'client-a', + durableLoaded: true, + ); +} + +class _R2Repository implements ConvexStrategyRepository { + _R2Repository({this.attachGate}); + + /// Holds each attach open until completed, to race it with the URL. + final Completer? attachGate; + final List completedAssetIds = []; + + @override + Future generateImageUploadUrl({ + required String strategyPublicId, + required String assetPublicId, + required String mimeType, + required String fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + return CloudImageUploadIntent( + provider: 'r2', + uploadId: 'upload-$assetPublicId', + objectKey: 'strategies/$strategyPublicId/$assetPublicId$fileExtension', + uploadUrl: 'https://r2.example.com/$assetPublicId', + requiredHeaders: {'Content-Type': mimeType}, + expiresAt: DateTime.now().add(const Duration(minutes: 15)), + maxBytes: 0, + ); + } + + @override + Future completeImageUpload({ + required String strategyPublicId, + required String assetPublicId, + String? provider, + String? uploadId, + String? objectKey, + String? storageId, + String? etag, + String? mimeType, + String? fileExtension, + int? byteSize, + int? width, + int? height, + }) async { + await attachGate?.future; + completedAssetIds.add(assetPublicId); + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +LineUpGroup _breachGroup() => LineUpGroup( + id: 'breach-group', + agent: PlacedAgent( + id: 'breach-agent', + type: AgentType.breach, + position: const Offset(180, 220), + isAlly: true, + ), + items: [ + LineUpItem( + id: 'breach-item', + ability: PlacedAbility( + id: 'breach-ability', + data: AgentData.agents[AgentType.breach]!.abilities.first, + position: const Offset(320, 360), + isAlly: true, + ), + ), + ], + ); + +/// Holds each write open until [putGate] completes, like a slow IndexedDB. +class _GatedBytesStore extends MemoryPendingMediaBytesStore { + Completer? putGate; + + @override + Future put(PendingMediaRecord record) async { + await putGate?.future; + await super.put(record); + } +} + +/// Holds each outbox batch write open until [batchGate] completes. +class _GatedOutboxStore extends MemoryDurableCloudMediaOutboxStore { + Completer? batchGate; + + @override + Future putAll(Iterable jobs) async { + await batchGate?.future; + await super.putAll(jobs); + } +} + +class _OnePngPicker extends FilePicker { + _OnePngPicker(this.bytes); + final Uint8List bytes; + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = true, + int compressionQuality = 30, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + bool readSequential = false, + }) async => + FilePickerResult([ + PlatformFile(name: 'smoke.png', size: bytes.length, bytes: bytes), + ]); +} + +/// One browser tab. Sharing the stores between two of these is a second tab +/// or a refresh. +ProviderContainer _webSession({ + required MemoryDurableCloudMediaOutboxStore mediaStore, + required MemoryPendingMediaBytesStore bytesStore, + String accountId = 'account-a', + MemoryDurableStrategyOutboxStore? strategyStore, + bool online = false, + ConvexStrategyRepository? repository, +}) { + return ProviderContainer( + overrides: [ + imageFilesOnDeviceProvider.overrideWithValue(false), + pendingMediaBytesStoreProvider.overrideWithValue(bytesStore), + durableCloudMediaOutboxStoreProvider.overrideWithValue(mediaStore), + durableStrategyOutboxStoreProvider.overrideWithValue( + strategyStore ?? MemoryDurableStrategyOutboxStore(), + ), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + authProvider.overrideWith(() => _Auth(ready: online)), + cloudMediaAccountIdProvider.overrideWithValue(accountId), + cloudCollabModeProvider.overrideWith(_CloudMode.new), + convexConnectionSnapshotProvider.overrideWithValue(online), + convexConnectionProvider.overrideWith((ref) => Stream.value(online)), + strategyProvider.overrideWith(_CloudStrategy.new), + strategyOpQueueProvider.overrideWith(_OpQueue.new), + cloudMediaReferenceLoaderProvider.overrideWithValue( + (_, __) async => throw StateError('no server reads in this test'), + ), + ], + ); +} + +/// The page op that places the image, already in the durable outbox. +Future _placedImageOp() async { + final ops = MemoryDurableStrategyOutboxStore(); + await ops.put(DurableOutboxRecord( + accountId: 'account-a', + strategyPublicId: 'strategy-a', + entityKey: const EntitySyncKey.element('page-a', _imageId), + pending: const PendingOp( + clientId: 'client-a', + op: ElementAddOp( + opId: 'place-image', + elementPublicId: _imageId, + pagePublicId: 'page-a', + sortIndex: 0, + payload: {'id': _imageId}, + ), + ), + status: DurableOutboxStatus.queued, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + )); + return ops; +} + +RemoteImageAsset _activeAsset(String id) => RemoteImageAsset( + publicId: id, + fileExtension: '.png', + width: 16, + height: 9, + url: 'https://media.example.com/$id.png', + legacyStoragePath: null, + uploadStatus: 'active', + ); + +Future _settle() => + Future.delayed(const Duration(milliseconds: 50)); + +Future _until(bool Function() done) async { + for (var i = 0; i < 60 && !done(); i++) { + await _settle(); + } + await _settle(); +} + +/// Runs [session]'s queue until [repository] has attached [count] images, +/// answering every PUT with 200 and recording it in [puts]. +Future _uploadAll( + ProviderContainer Function() session, + _R2Repository repository, + List puts, { + int count = 1, +}) { + final client = MockClient((request) async { + puts.add(request); + return http.Response('', 200, headers: {'etag': '"etag"'}); + }); + return http.runWithClient(() async { + final container = session(); + await container + .read(cloudMediaUploadQueueProvider.notifier) + .retryNow(ignoreBackoff: true); + await _until(() => repository.completedAssetIds.length >= count); + }, () => client); +} + +Future _pumpToasts(WidgetTester tester) => tester.pumpWidget( + const ToastificationWrapper(child: MaterialApp(home: SizedBox())), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'picked bytes are saved before the job, survive a refresh, and upload', + (tester) async { + await _pumpToasts(tester); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final bytesStore = MemoryPendingMediaBytesStore(); + + // Offline: the picked image is saved and queued, nothing is sent. + await tester.runAsync(() async { + final first = _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + await first.read(placedImageProvider.notifier).saveSecureImage( + _imageBytes, + _imageId, + '.png', + strategyId: 'strategy-a', + ); + await first + .read(cloudMediaUploadQueueProvider.notifier) + .enqueuePlacedImageUpload( + strategyPublicId: 'strategy-a', + imagePublicId: _imageId, + fileExtension: '.png', + ); + expect( + bytesStore.values[pendingMediaStorageKey(_key(_imageId))]?.bytes, + _imageBytes, + ); + expect(mediaStore.values, contains('account-a|$_imageId')); + first.dispose(); + }); + + // Refresh, back online: the restored job sends the restored bytes. + final repository = _R2Repository(); + final puts = []; + final strategyStore = await tester.runAsync(_placedImageOp); + late ProviderContainer restarted; + await tester.runAsync(() => _uploadAll( + () => restarted = _webSession( + mediaStore: mediaStore, + bytesStore: bytesStore, + strategyStore: strategyStore, + online: true, + repository: repository, + ), + repository, + puts, + )); + addTearDown(restarted.dispose); + + expect(puts, hasLength(1)); + expect(puts.single.method, 'PUT'); + expect(puts.single.bodyBytes, _imageBytes); + expect(puts.single.headers['Content-Type'], startsWith('image/png')); + expect(repository.completedAssetIds, [_imageId]); + // Synced: nothing left to upload and nothing left in browser storage, + // but the editor keeps painting the bytes until the cloud URL arrives. + expect(mediaStore.values, isEmpty); + expect(bytesStore.values, isEmpty); + expect( + restarted + .read(pendingMediaBytesProvider.notifier) + .bytesFor(_key(_imageId)), + _imageBytes, + ); + }); + + testWidgets( + 'two accounts with the same asset ID each upload their own bytes, ' + 'and one attach leaves the other alone', (tester) async { + await _pumpToasts(tester); + final bytesA = Uint8List.fromList([1, 1, 1]); + final bytesB = Uint8List.fromList([2, 2, 2, 2]); + const shared = 'shared-image'; + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.putAll([ + _job(shared, accountId: 'account-a'), + _job(shared, accountId: 'account-b'), + ]); + final bytesStore = MemoryPendingMediaBytesStore([ + _record(shared, accountId: 'account-a', bytes: bytesA), + _record(shared, accountId: 'account-b', bytes: bytesB), + ]); + + final repositoryB = _R2Repository(); + final putsB = []; + late ProviderContainer sessionB; + await tester.runAsync(() => _uploadAll( + () => sessionB = _webSession( + mediaStore: mediaStore, + bytesStore: bytesStore, + accountId: 'account-b', + online: true, + repository: repositoryB, + ), + repositoryB, + putsB, + )); + addTearDown(sessionB.dispose); + + expect(putsB.single.bodyBytes, bytesB); + expect(mediaStore.values.keys, ['account-a|$shared']); + expect( + bytesStore.values.keys, + [pendingMediaStorageKey(_key(shared, accountId: 'account-a'))], + ); + + // A signs back in; its own upload still has its own bytes. + final repositoryA = _R2Repository(); + final putsA = []; + late ProviderContainer sessionA; + await tester.runAsync(() => _uploadAll( + () => sessionA = _webSession( + mediaStore: mediaStore, + bytesStore: bytesStore, + online: true, + repository: repositoryA, + ), + repositoryA, + putsA, + )); + addTearDown(sessionA.dispose); + + expect(putsA.single.bodyBytes, bytesA); + expect(mediaStore.values, isEmpty); + expect(bytesStore.values, isEmpty); + }); + + test('reconciling as one account never borrows another account\'s bytes', + () async { + final bytesStore = MemoryPendingMediaBytesStore([ + _record('a-image', accountId: 'account-a'), + ]); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final sessionB = _webSession( + mediaStore: mediaStore, + bytesStore: bytesStore, + accountId: 'account-b', + ); + addTearDown(sessionB.dispose); + + await sessionB + .read(cloudMediaUploadQueueProvider.notifier) + .reconcilePageMedia( + strategyPublicId: 'strategy-a', + placedImages: [ + PlacedImage( + id: 'a-image', + position: Offset.zero, + aspectRatio: 1, + scale: 1, + fileExtension: '.png', + ), + ], + assetsById: const {}, + ); + + expect(mediaStore.values, isEmpty); + expect(bytesStore.values, hasLength(1)); + }); + + test('reconciling a page on web reads no image folder', () async { + final bytesStore = MemoryPendingMediaBytesStore(); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final container = + _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final image = PlacedImage( + id: 'lost-image', + position: Offset.zero, + aspectRatio: 1, + scale: 1, + fileExtension: '.png', + ); + + // No bytes here and no folder to look in: the image is skipped. + await queue.reconcilePageMedia( + strategyPublicId: 'strategy-a', + placedImages: [image], + assetsById: const {}, + ); + expect(container.read(cloudMediaUploadQueueProvider).jobs, isEmpty); + + // Bytes still pending from this browser are queued again. + await container + .read(pendingMediaBytesProvider.notifier) + .put(_key('lost-image'), _imageBytes); + await queue.reconcilePageMedia( + strategyPublicId: 'strategy-a', + placedImages: [image], + assetsById: const {}, + ); + expect( + container.read(cloudMediaUploadQueueProvider).jobs.single.assetPublicId, + 'lost-image', + ); + }); + + group('the painted copy goes once attached and served from the cloud', () { + Future race({required bool urlFirst}) async { + final gate = Completer(); + final repository = _R2Repository(attachGate: gate); + final bytesStore = MemoryPendingMediaBytesStore([_record(_imageId)]); + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put( + _job(_imageId, state: CloudMediaJobState.pendingAttach), + ); + final container = _webSession( + mediaStore: mediaStore, + bytesStore: bytesStore, + online: true, + repository: repository, + ); + addTearDown(container.dispose); + final queue = container.read(cloudMediaUploadQueueProvider.notifier); + final pending = container.read(pendingMediaBytesProvider.notifier); + Future serveFromCloud() => queue.reconcilePageMedia( + strategyPublicId: 'strategy-a', + placedImages: const [], + assetsById: {_imageId: _activeAsset(_imageId)}, + ); + + await queue.retryNow(ignoreBackoff: true); + if (urlFirst) { + // The page serves the URL while the attach is still in flight. + await serveFromCloud(); + expect(pending.bytesFor(_key(_imageId)), _imageBytes); + } + gate.complete(); + await _until(() => repository.completedAssetIds.isNotEmpty); + expect(bytesStore.values, isEmpty); + if (!urlFirst) { + expect(pending.bytesFor(_key(_imageId)), _imageBytes); + await serveFromCloud(); + } + + expect(pending.bytesFor(_key(_imageId)), isNull); + expect(container.read(pendingMediaBytesProvider), isEmpty); + } + + test('attach lands, then the URL arrives', () => race(urlFirst: false)); + test('the URL arrives, then attach lands', () => race(urlFirst: true)); + }); + + group('launch prune', () { + test('a draft another tab is editing survives, then a refresh recovers it', + () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + final bytesStore = MemoryPendingMediaBytesStore(); + final tabA = _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(tabA.dispose); + tabA.read(cloudMediaUploadQueueProvider); + final drafts = LineupImageDrafts( + tabA.read(placedImageProvider.notifier), + strategyId: 'strategy-a', + ); + final draft = (await drafts.add(_imageBytes, '.png'))!; + + // Tab B opens while the lineup dialog in tab A is still open. + final tabB = _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(tabB.dispose); + tabB.read(cloudMediaUploadQueueProvider); + await _settle(); + expect(bytesStore.values, hasLength(1)); + + // Tab A saves the lineup, then the browser refreshes. + drafts.handOver(); + await tabA + .read(cloudMediaUploadQueueProvider.notifier) + .enqueueLineupMediaJobs(strategyPublicId: 'strategy-a', images: [ + draft, + ]); + final refreshed = + _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(refreshed.dispose); + final jobs = refreshed.read(cloudMediaUploadQueueProvider).jobs; + await _settle(); + + expect(jobs.single.assetPublicId, draft.id); + expect( + refreshed + .read(pendingMediaBytesProvider.notifier) + .bytesFor(_key(draft.id)), + _imageBytes, + ); + }); + + test('only a week-old draft with no job is dropped', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore(); + await mediaStore.put(_job('old-queued', accountId: 'account-b')); + final bytesStore = MemoryPendingMediaBytesStore([ + _record('abandoned', age: const Duration(days: 8)), + _record('old-queued', + accountId: 'account-b', age: const Duration(days: 8)), + _record('recent-draft', age: const Duration(days: 1)), + ]); + final container = + _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(container.dispose); + + container.read(cloudMediaUploadQueueProvider); + await _settle(); + + expect( + bytesStore.values.keys.toSet(), + { + pendingMediaStorageKey(_key('old-queued', accountId: 'account-b')), + pendingMediaStorageKey(_key('recent-draft')), + }, + ); + }); + + test('unreadable saved work drops no bytes', () async { + final mediaStore = MemoryDurableCloudMediaOutboxStore({ + 'account-a|unreadable': {'outboxVersion': 99}, + }); + final bytesStore = MemoryPendingMediaBytesStore([ + _record('maybe-needed', age: const Duration(days: 30)), + ]); + final container = + _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + addTearDown(container.dispose); + + container.read(cloudMediaUploadQueueProvider); + await _settle(); + + expect(bytesStore.values, hasLength(1)); + }); + }); + + group('lineup drafts', () { + late _GatedBytesStore bytesStore; + late ProviderContainer container; + late LineupImageDrafts drafts; + + setUp(() { + bytesStore = _GatedBytesStore(); + container = _webSession( + mediaStore: MemoryDurableCloudMediaOutboxStore(), + bytesStore: bytesStore, + ); + drafts = LineupImageDrafts( + container.read(placedImageProvider.notifier), + strategyId: 'strategy-a', + ); + }); + tearDown(() => container.dispose()); + + test('removing a draft lets its bytes go', () async { + final kept = (await drafts.add(_imageBytes, '.png'))!; + final removed = (await drafts.add(_imageBytes, '.png'))!; + + await drafts.remove(removed.id); + + expect(bytesStore.values.keys, [pendingMediaStorageKey(_key(kept.id))]); + final painted = container.read(pendingMediaBytesProvider); + expect(painted.keys, [pendingMediaStorageKey(_key(kept.id))]); + }); + + test('dismissing the dialog lets every draft go', () async { + await drafts.add(_imageBytes, '.png'); + await drafts.add(_imageBytes, '.png'); + + await drafts.dismissed(); + + expect(bytesStore.values, isEmpty); + expect(container.read(pendingMediaBytesProvider), isEmpty); + }); + + test('drafts handed to the queue survive the dialog closing', () async { + final saved = (await drafts.add(_imageBytes, '.png'))!; + drafts.handOver(); + + await drafts.dismissed(); + await drafts.remove(saved.id); + + expect(bytesStore.values, hasLength(1)); + }); + + test('drafts whose queuing failed go when the dialog has closed', () async { + await drafts.add(_imageBytes, '.png'); + final handedOver = drafts.handOver(); + await drafts.dismissed(); + + await drafts.takeBack(handedOver); + + expect(bytesStore.values, isEmpty); + }); + + test('a pick that finishes after the dialog closed keeps nothing', + () async { + bytesStore.putGate = Completer(); + final pick = drafts.add(_imageBytes, '.png'); + + await drafts.dismissed(); + bytesStore.putGate!.complete(); + + expect(await pick, isNull); + expect(bytesStore.values, isEmpty); + expect(container.read(pendingMediaBytesProvider), isEmpty); + }); + + test('an image over 15 MB is refused before anything is kept', () async { + final tooLarge = Uint8List(maxCloudImageBytes + 1); + + await expectLater( + drafts.add(tooLarge, '.png'), + throwsA(isA()), + ); + + expect(bytesStore.values, isEmpty); + expect(container.read(pendingMediaBytesProvider), isEmpty); + }); + }); + + testWidgets( + 'dismissing while Save queues an edited lineup keeps the queued ' + 'image\'s bytes', (tester) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final mediaStore = _GatedOutboxStore()..batchGate = Completer(); + final bytesStore = MemoryPendingMediaBytesStore(); + // Disposed at the end of the body, before flutter_test checks timers. + final container = + _webSession(mediaStore: mediaStore, bytesStore: bytesStore); + container + .read(lineUpProvider.notifier) + .fromHive(LineUpGraph.fromLegacyGroups([_breachGroup()])); + final linkId = container.read(lineUpProvider).links.single.id; + // A real 1x1 PNG: the dialog paints the pick as soon as it is kept. + final png = base64Decode( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + ); + FilePicker.platform = _OnePngPicker(png); + + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showShadDialog( + context: context, + builder: (_) => CreateLineupDialog(linkId: linkId), + ), + child: const Text('Edit lineup'), + ), + ), + ), + ), + ), + )); + + // Edit the lineup and pick an image. + await tester.tap(find.text('Edit lineup')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Click here to add images')); + await tester.pumpAndSettle(); + expect(bytesStore.values, hasLength(1)); + + // Done, then every way to dismiss while the outbox write is pending. + await tester.tap(find.text('Done')); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + await tester.tapAt(const Offset(5, 5)); + await tester.pump(); + expect(find.byType(CreateLineupDialog), findsOneWidget); + + mediaStore.batchGate!.complete(); + await tester.pumpAndSettle(); + + final job = mediaStore.load().jobs.single; + expect( + bytesStore.values.keys, + [pendingMediaStorageKey(_key(job.assetPublicId))], + ); + expect( + container.read(lineUpProvider).linkById(linkId)!.images.single.id, + job.assetPublicId, + ); + expect(find.byType(CreateLineupDialog), findsNothing); + // Let any toast from the save time out, then stop the queue's retry + // timer before the test ends. + await tester.pump(const Duration(seconds: 10)); + await tester.pumpWidget(const SizedBox()); + container.dispose(); + }); + + test('desktop never opens the pending-bytes box', () async { + // No Hive box is open here, as on desktop; reading must not need one. + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(imageFilesOnDeviceProvider), isTrue); + expect( + container.read(pendingMediaBytesStoreProvider), + isA(), + ); + expect(container.read(pendingMediaBytesProvider), isEmpty); + expect(Hive.isBoxOpen(HiveBoxNames.pendingMediaBytesBox), isFalse); + }); + + test('browser storage keeps pending bytes across a reopen', () async { + final directory = await Directory.systemTemp.createTemp('icarus-bytes-'); + try { + Hive.init(directory.path); + await Hive.openBox(HiveBoxNames.pendingMediaBytesBox); + final savedAt = DateTime.utc(2026, 9, 24, 12); + await HivePendingMediaBytesStore().put(PendingMediaRecord( + key: _key(_imageId), + bytes: _imageBytes, + savedAt: savedAt, + )); + await Hive.close(); + + await Hive.openBox(HiveBoxNames.pendingMediaBytesBox); + final store = HivePendingMediaBytesStore(); + final record = store.load().single; + expect(record.key, _key(_imageId)); + expect(record.bytes, _imageBytes); + expect(record.savedAt.isAtSameMomentAs(savedAt), isTrue); + await store.remove(_key(_imageId)); + expect(store.load(), isEmpty); + } finally { + await Hive.close(); + await directory.delete(recursive: true); + } + }); +} diff --git a/test/widgets/account_avatar_test.dart b/test/widgets/account_avatar_test.dart new file mode 100644 index 00000000..370d8953 --- /dev/null +++ b/test/widgets/account_avatar_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/account_avatar.dart'; + +void main() { + testWidgets('falls back without reporting a UI error when avatar load fails', + (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: AccountAvatar( + radius: 16, + backgroundColor: Colors.black, + avatarUrl: 'https://invalid.invalid/avatar.png', + fallback: Text('D'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('D'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/widgets/cloud_beta_automation_semantics_test.dart b/test/widgets/cloud_beta_automation_semantics_test.dart new file mode 100644 index 00000000..f1ff1e18 --- /dev/null +++ b/test/widgets/cloud_beta_automation_semantics_test.dart @@ -0,0 +1,289 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/custom_text_field.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/library_title_strip.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('shared text fields expose live editable semantics', + (tester) async { + final semanticsHandle = tester.ensureSemantics(); + final controller = TextEditingController(text: 'child-final'); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _testApp( + CustomTextField( + key: const ValueKey('shared-editable-field'), + controller: controller, + hintText: 'Folder Name', + ), + ), + ); + + final node = tester.getSemantics( + find.byKey(const ValueKey('shared-editable-field')), + ); + expect(node.flagsCollection.isEnabled, ui.Tristate.isTrue); + expect(node.getSemanticsData().hasAction(SemanticsAction.setText), isTrue); + expect(node.label, 'Folder Name'); + expect(node.value, 'child-final'); + + tester.semantics.performAction( + find.semantics.byLabel('Folder Name'), + SemanticsAction.setText, + args: 'child-renamed-web', + ); + await tester.pump(); + + expect(controller.text, 'child-renamed-web'); + expect( + tester + .getSemantics(find.byKey(const ValueKey('shared-editable-field'))) + .value, + 'child-renamed-web', + ); + semanticsHandle.dispose(); + }); + + testWidgets('auth dialog exposes stable fields and actions', (tester) async { + final semanticsHandle = tester.ensureSemantics(); + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_testApp(const AuthDialog())); + + expect(find.byKey(const ValueKey('auth-email-field')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-password-field')), findsOneWidget); + expect(_semanticsLabel('Email'), findsOneWidget); + expect(_semanticsLabel('Password'), findsOneWidget); + expect(find.byKey(const ValueKey('auth-mode-switch')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-discord-button')), findsOneWidget); + expect(find.byKey(const ValueKey('auth-submit-button')), findsOneWidget); + expect(_textFieldNodes(tester), hasLength(2)); + expect( + _textFieldNodes(tester).map((node) => node.flagsCollection.isEnabled), + everyElement(ui.Tristate.isTrue), + ); + expect( + _textFieldNodes(tester).map( + (node) => node.getSemanticsData().hasAction(SemanticsAction.setText), + ), + everyElement(isTrue), + ); + tester.semantics.performAction( + find.semantics.byLabel('Email'), + SemanticsAction.setText, + args: 'coach@example.com', + ); + await tester.pump(); + expect( + tester.getSemantics(find.byKey(const ValueKey('auth-email-field'))).value, + 'coach@example.com', + ); + + await tester.tap(find.byKey(const ValueKey('auth-mode-switch'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('auth-confirm-password-field')), + findsOneWidget, + ); + expect(_semanticsLabel('Confirm password'), findsOneWidget); + expect(find.text('Create account'), findsAtLeastNWidgets(1)); + expect(_textFieldNodes(tester), hasLength(3)); + semanticsHandle.dispose(); + }); + + testWidgets('library strip exposes stable destinations while signed out', + (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_testApp(_strip())); + + expect(find.byKey(const ValueKey('library-tab-library')), findsOneWidget); + expect(find.byKey(const ValueKey('library-tab-shared')), findsOneWidget); + expect( + find.byKey(const ValueKey('library-tab-community')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('library-account-action')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('library-new-menu')), findsOneWidget); + expect(find.byKey(const ValueKey('library-sort-menu')), findsOneWidget); + expect(_semanticsLabel('My Library'), findsOneWidget); + expect(_semanticsLabel('Shared library'), findsOneWidget); + expect(_semanticsLabel('Community library'), findsOneWidget); + expect(_semanticsLabel('Log in to Icarus'), findsOneWidget); + expect(_semantics('My Library').properties.onTap, isNotNull); + expect(_semantics('Community library').properties.onTap, isNotNull); + expect(_semantics('Log in to Icarus').properties.onTap, isNotNull); + }); + + testWidgets('signed-out Shared tab opens login', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_testApp(_strip())); + + await tester.tap(find.byKey(const ValueKey('library-tab-shared'))); + await tester.pumpAndSettle(); + + expect(find.byType(AuthDialog), findsOneWidget); + expect(find.text('Sign in'), findsAtLeastNWidgets(1)); + }); + + testWidgets('New menu offers a strategy and a folder', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + var created = 0; + await tester.pumpWidget( + _testApp(_strip(onCreateStrategy: () => created++)), + ); + + await tester.tap(find.byKey(const ValueKey('library-new-menu'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('library-new-strategy')), findsOneWidget); + expect(find.byKey(const ValueKey('library-new-folder')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('library-new-strategy'))); + await tester.pumpAndSettle(); + + expect(created, 1); + }); + testWidgets('library account action uses guarded sign out', (tester) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1280, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuthProvider.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: ShadApp(home: Scaffold(body: _strip())), + ), + ); + + await tester.tap(find.byKey(const ValueKey('library-account-action'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Sign Out')); + await tester.pumpAndSettle(); + expect(requests, 1); + }); +} + +Widget _strip({VoidCallback? onCreateStrategy}) { + return SizedBox( + width: 1200, + height: 40, + child: LibraryTitleStrip( + onCreateStrategy: onCreateStrategy ?? () {}, + onCreateFolder: () {}, + onImportIca: () {}, + onImportBackup: () {}, + onExportLibrary: () {}, + ), + ); +} + +Semantics _semantics(String label) { + return _semanticsLabel(label).evaluate().single.widget as Semantics; +} + +List _textFieldNodes(WidgetTester tester) { + final nodes = []; + + void visit(SemanticsNode node) { + if (node.flagsCollection.isTextField) { + nodes.add(node); + } + node.visitChildren((child) { + visit(child); + return true; + }); + } + + final root = tester + .binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode; + if (root != null) { + visit(root); + } + return nodes; +} + +Finder _semanticsLabel(String label) { + return find.byWidgetPredicate( + (widget) => widget is Semantics && widget.properties.label == label, + ); +} + +Widget _testApp(Widget child) { + return ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedOutAuthProvider.new), + ], + child: ShadApp( + home: Scaffold(body: child), + ), + ); +} + +class _SignedOutAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, + ); +} + +class _SignedInAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} diff --git a/test/widgets/cloud_library_action_dialogs_test.dart b/test/widgets/cloud_library_action_dialogs_test.dart new file mode 100644 index 00000000..cca44942 --- /dev/null +++ b/test/widgets/cloud_library_action_dialogs_test.dart @@ -0,0 +1,392 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/convex_client.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/services/cloud_library_action.dart'; +import 'package:icarus/services/cloud_strategy_export.dart'; +import 'package:icarus/strategy/strategy_import_export.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/delete_folder_alert_dialog.dart'; +import 'package:icarus/widgets/dialogs/strategy/delete_strategy_alert_dialog.dart'; +import 'package:icarus/widgets/folder_edit_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('folder edit failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..editResults.add(firstAttempt.future) + ..editResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [ + _folderProviderOverride(folderProvider), + ], + dialog: FolderEditDialog(folder: _folder()), + ); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pump(); + expect(folderProvider.editCalls, 1); + expect(find.text('Saving...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't update this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(FolderEditDialog), findsOneWidget); + expect( + find.text("Couldn't update this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pumpAndSettle(); + expect(folderProvider.editCalls, 2); + expect(find.byType(FolderEditDialog), findsNothing); + }); + + testWidgets('folder delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final folderProvider = _ControlledFolderProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_folderProviderOverride(folderProvider)], + dialog: DeleteFolderAlertDialog( + folder: _folder(), + workspace: LibraryWorkspace.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pump(); + expect(folderProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud folder. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteFolderAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud folder. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-folder-confirm'))); + await tester.pumpAndSettle(); + expect(folderProvider.deleteCalls, 2); + expect(find.byType(DeleteFolderAlertDialog), findsNothing); + }); + + testWidgets('strategy delete failure stays open, is safe, and can retry', + (tester) async { + final firstAttempt = Completer(); + final strategyProvider = _ControlledStrategyProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_strategyProviderOverride(strategyProvider)], + dialog: const DeleteStrategyAlertDialog( + strategyID: 'strategy-1', + name: 'A Split', + source: StrategySource.cloud, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pump(); + expect(strategyProvider.deleteCalls, 1); + expect(find.text('Deleting...'), findsOneWidget); + + firstAttempt.complete( + CloudLibraryActionResult.failed( + "Couldn't delete this cloud strategy. Try again.", + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(DeleteStrategyAlertDialog), findsOneWidget); + expect( + find.text("Couldn't delete this cloud strategy. Try again."), + findsOneWidget, + ); + expect(find.text(_secret), findsNothing); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pumpAndSettle(); + expect(strategyProvider.deleteCalls, 2); + expect(find.byType(DeleteStrategyAlertDialog), findsNothing); + }); + + testWidgets('thrown strategy delete resets the dialog and can retry', + (tester) async { + final firstAttempt = Completer(); + final strategyProvider = _ControlledStrategyProvider() + ..deleteResults.add(firstAttempt.future) + ..deleteResults.add(Future.value(CloudLibraryActionResult.succeeded)); + await _pumpDialogLauncher( + tester, + overrides: [_strategyProviderOverride(strategyProvider)], + dialog: const DeleteStrategyAlertDialog( + strategyID: 'strategy-1', + name: 'A Split', + source: StrategySource.local, + ), + ); + + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + firstAttempt.completeError(StateError(_secret)); + await tester.pumpAndSettle(); + + expect( + find.text("Couldn't delete this strategy. Try again."), findsOneWidget); + expect(find.text(_secret), findsNothing); + await tester.tap(find.byKey(const ValueKey('delete-strategy-confirm'))); + await tester.pumpAndSettle(); + expect(strategyProvider.deleteCalls, 2); + expect(find.byType(DeleteStrategyAlertDialog), findsNothing); + }); + + testWidgets('cloud export failure reports one generic message', + (tester) async { + final messages = []; + var exportCalls = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async { + exportCalls += 1; + throw StateError(_secret); + }), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(exportCalls, 1); + expect(messages, ["Couldn't export this cloud strategy. Try again."]); + expect(messages.single, isNot(contains(_secret))); + }); + + testWidgets('cancelled cloud export stays silent', (tester) async { + final messages = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + cloudStrategyExporterProvider.overrideWithValue((_) async => false), + cloudLibraryActionReporterProvider.overrideWithValue( + CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ), + ), + authProvider.overrideWith(_ReadyAuthProvider.new), + ], + child: const ShadApp( + home: Scaffold(body: _CloudExportInvoker()), + ), + ), + ); + + await tester.tap(find.text('Export cloud strategy')); + await tester.pumpAndSettle(); + + expect(messages, isEmpty); + }); + + test('auth failures use the incident path without a generic message', + () async { + final messages = []; + var authReports = 0; + final reporter = CloudLibraryActionReporter( + showMessage: messages.add, + reportTechnicalFailure: ({ + required source, + required error, + required stackTrace, + }) {}, + ); + + final result = await reporter.run( + action: () async => throw const ConvexClientFunctionError( + rawCode: 'UNAUTHENTICATED', + message: 'Authentication required', + data: null, + ), + source: 'test:auth', + failureMessage: 'This must not be shown.', + showFailureMessage: true, + reportAuthenticationFailure: (_, __) async => authReports += 1, + ); + + expect(result.status, CloudLibraryActionStatus.authenticationRequired); + expect(result.userMessage, 'Reconnect to Icarus Cloud, then try again.'); + expect(authReports, 1); + expect(messages, isEmpty); + }); +} + +const _secret = 'Bearer super-secret-backend-detail'; + +Override _folderProviderOverride(_ControlledFolderProvider notifier) => + folderProvider.overrideWith(() => notifier); + +Override _strategyProviderOverride(_ControlledStrategyProvider notifier) => + strategyProvider.overrideWith(() => notifier); + +Future _pumpDialogLauncher( + WidgetTester tester, { + required List overrides, + required Widget dialog, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showShadDialog( + context: context, + builder: (_) => dialog, + ), + child: const Text('Open dialog'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open dialog')); + await tester.pumpAndSettle(); +} + +class _ControlledFolderProvider extends FolderProvider { + final Queue> editResults = Queue(); + final Queue> deleteResults = Queue(); + int editCalls = 0; + int deleteCalls = 0; + + @override + String? build() => null; + + @override + Future editFolder({ + required Folder folder, + required String newName, + required int newIconId, + required FolderColor newColor, + required Color? newCustomColor, + LibraryWorkspace? workspace, + }) { + editCalls += 1; + return editResults.removeFirst(); + } + + @override + Future deleteFolder( + String folderID, { + LibraryWorkspace? workspace, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _ControlledStrategyProvider extends StrategyProvider { + final Queue> deleteResults = Queue(); + int deleteCalls = 0; + + @override + StrategyState build() => const StrategyState( + strategyId: null, + strategyName: null, + source: null, + storageDirectory: null, + isOpen: false, + ); + + @override + Future deleteStrategy( + String strategyID, { + StrategySource? source, + }) { + deleteCalls += 1; + return deleteResults.removeFirst(); + } +} + +class _CloudExportInvoker extends ConsumerWidget { + const _CloudExportInvoker(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ShadButton( + onPressed: () => runCloudStrategyExport(ref, 'strategy-1'), + child: const Text('Export cloud strategy'), + ); + } +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +Folder _folder() => Folder( + id: 'folder-1', + name: 'Defaults', + iconId: 0, + dateCreated: DateTime.utc(2026), + color: FolderColor.generic, + ); diff --git a/test/widgets/cloud_library_empty_states_test.dart b/test/widgets/cloud_library_empty_states_test.dart new file mode 100644 index 00000000..13ce56be --- /dev/null +++ b/test/widgets/cloud_library_empty_states_test.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/const/coordinate_system.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/widgets/dialogs/share_links_dialog.dart'; +import 'package:icarus/widgets/folder_content.dart'; +import 'package:icarus/widgets/text_editing_shortcut_scope.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + CoordinateSystem(playAreaSize: const Size(1280, 720)); + tempDir = await Directory.systemTemp.createTemp('icarus-library-'); + Hive.init(tempDir.path); + // My Library lists the local store next to the cloud one, so the widget + // reads these boxes even in cloud mode. + await Hive.openBox(HiveBoxNames.strategiesBox); + await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + testWidgets('empty library leads to creating a strategy', (tester) async { + var createCount = 0; + await tester.pumpWidget( + _cloudApp( + FolderContent(onCreateStrategy: () => createCount++), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('library-empty-state')), findsOneWidget); + expect(find.text('Your library is empty'), findsOneWidget); + expect( + find.byKey(const ValueKey('library-empty-create-strategy')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const ValueKey('library-empty-create-strategy')), + ); + expect(createCount, 1); + }); + + testWidgets('empty Shared leads to adding a link or code', (tester) async { + await tester.pumpWidget( + _cloudApp( + FolderContent(onCreateStrategy: () {}), + section: CloudLibrarySection.sharedWithMe, + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('shared-empty-state')), findsOneWidget); + expect(find.text('Nothing shared with you yet'), findsOneWidget); + expect( + find.byKey(const ValueKey('shared-empty-add-item')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const ValueKey('shared-empty-add-item'))); + await tester.pumpAndSettle(); + + expect(find.byType(AddSharedItemDialog), findsOneWidget); + expect( + find.descendant( + of: find.byType(AddSharedItemDialog), + matching: find.byType(TextEditingShortcutScope), + ), + findsOneWidget, + ); + }); +} + +Widget _cloudApp( + Widget child, { + CloudLibrarySection section = CloudLibrarySection.home, +}) { + return ProviderScope( + overrides: [ + authProvider.overrideWith(_CloudReadyAuthProvider.new), + libraryWorkspaceProvider.overrideWith(_CloudWorkspaceNotifier.new), + cloudLibrarySectionProvider.overrideWith( + () => _CloudSectionNotifier(section), + ), + cloudFolderTreeProvider.overrideWith( + (_) => Stream.value(const []), + ), + cloudStrategiesProvider.overrideWith( + (_) => Stream.value(const []), + ), + ], + child: ShadApp(home: Scaffold(body: child)), + ); +} + +class _CloudReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} + +class _CloudWorkspaceNotifier extends LibraryWorkspaceNotifier { + @override + LibraryWorkspace build() => LibraryWorkspace.cloud; +} + +class _CloudSectionNotifier extends CloudLibrarySectionNotifier { + _CloudSectionNotifier(this._section); + + final CloudLibrarySection _section; + + @override + CloudLibrarySection build() => _section; +} diff --git a/test/widgets/cloud_outbox_summary_banner_test.dart b/test/widgets/cloud_outbox_summary_banner_test.dart new file mode 100644 index 00000000..fad0df90 --- /dev/null +++ b/test/widgets/cloud_outbox_summary_banner_test.dart @@ -0,0 +1,405 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/widgets/cloud_outbox_summary_banner.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('closed-strategy attention is visible with no editor open', + (tester) async { + String? openedStrategy; + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: null, + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'strategy-needs-review': StrategyOutboxSummary( + strategyPublicId: 'strategy-needs-review', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'ClientException: socket failed, bearer=secret', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + await _pump( + tester, + container, + CloudOutboxSummaryBanner( + onOpenStrategy: (id) => openedStrategy = id, + ), + ); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('Haven retake'), findsOneWidget); + expect(find.text('Haven retake: review sync'), findsOneWidget); + expect(find.textContaining('ClientException'), findsNothing); + expect(find.textContaining('bearer=secret'), findsNothing); + await tester.tap(find.textContaining('Haven retake')); + expect(openedStrategy, 'strategy-needs-review'); + }); + + testWidgets('a deleted strategy offers Discard instead of review', + (tester) async { + final queue = _Queue(const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + deletedStrategies: {'gone': 2}, + ), + )); + final media = _MediaQueue(); + final container = ProviderContainer(overrides: [ + authProvider.overrideWith(() => _ReadyAuth(true)), + libraryWorkspaceProvider + .overrideWith(() => _Workspace(LibraryWorkspace.cloud)), + cloudLibrarySectionProvider + .overrideWith(() => _CloudSection(CloudLibrarySection.home)), + strategyOpQueueProvider.overrideWith(() => queue), + cloudMediaUploadQueueProvider.overrideWith(() => media), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + cloudStrategyNamesProvider.overrideWithValue(const {}), + ]); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect( + find.textContaining('2 unsent changes to a strategy that was deleted'), + findsOneWidget, + ); + await tester.tap(find.text('Discard changes to the deleted strategy')); + await tester.pump(); + + expect(queue.discarded, ['gone']); + expect(media.cleared, ['gone']); + }); + + testWidgets('queued closed-strategy work is visible while offline', + (tester) async { + final container = _container( + connected: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 2, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + await tester.pump(); + + expect(find.text('Working offline'), findsOneWidget); + expect(find.textContaining('2 saved changes'), findsOneWidget); + expect(find.textContaining('waiting on this device'), findsOneWidget); + }); + + testWidgets('pending shared-strategy work is visible in Shared With Me', + (tester) async { + final container = _container( + section: CloudLibrarySection.sharedWithMe, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'shared-strategy': StrategyOutboxSummary( + strategyPublicId: 'shared-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.byKey(const ValueKey('cloud-outbox-summary')), findsOneWidget); + expect(find.text('Syncing cloud work'), findsOneWidget); + expect(find.textContaining('1 saved change'), findsOneWidget); + }); + + testWidgets('another account work is absent from this account library', + (tester) async { + final container = _container( + queue: const StrategyOpQueueState( + accountId: 'account-b', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary(accountId: 'account-b'), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('outbox banner stays absent from the local workspace', + (tester) async { + final container = _container( + workspace: LibraryWorkspace.local, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect( + find.byKey(const ValueKey('cloud-outbox-summary')), + findsNothing, + ); + }); + + testWidgets('auth-paused work shows the reason instead of syncing', + (tester) async { + final container = _container( + authReady: false, + queue: const StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect(find.textContaining('Reconnect this account'), findsOneWidget); + expect(find.text('Syncing cloud work'), findsNothing); + }); + + group('when the server needs a newer Icarus', () { + const queued = StrategyOpQueueState( + accountId: 'account-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed': StrategyOutboxSummary( + strategyPublicId: 'closed', + queuedCount: 2, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ); + + testWidgets('held work never claims to be syncing', (tester) async { + final container = _container(queue: queued); + addTearDown(container.dispose); + container + .read(clientUpgradeRequiredProvider.notifier) + .noteError(clientUpgradeRequiredQueueError); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Syncing cloud work'), findsNothing); + expect(find.textContaining('being sent'), findsNothing); + expect( + find.text('Cloud work is waiting for a newer Icarus'), + findsOneWidget, + ); + expect(find.textContaining('2 saved changes are kept on this device'), + findsOneWidget); + }); + + testWidgets('unreadable work outranks the refusal', (tester) async { + final container = _container( + queue: queued.copyWith(hasDurabilityFailure: true), + ); + addTearDown(container.dispose); + container + .read(clientUpgradeRequiredProvider.notifier) + .noteError(clientUpgradeRequiredQueueError); + await _pump(tester, container, const CloudOutboxSummaryBanner()); + + expect(find.text('Cloud work needs attention'), findsOneWidget); + expect( + find.text('Cloud work is waiting for a newer Icarus'), findsNothing); + expect(find.byKey(const ValueKey('client-upgrade-button')), findsNothing); + }); + }); +} + +ProviderContainer _container({ + required StrategyOpQueueState queue, + LibraryWorkspace workspace = LibraryWorkspace.cloud, + CloudLibrarySection section = CloudLibrarySection.home, + bool connected = true, + bool authReady = true, +}) { + return ProviderContainer(overrides: [ + authProvider.overrideWith(() => _ReadyAuth(authReady)), + libraryWorkspaceProvider.overrideWith(() => _Workspace(workspace)), + cloudLibrarySectionProvider.overrideWith(() => _CloudSection(section)), + strategyOpQueueProvider.overrideWith(() => _Queue(queue)), + cloudMediaUploadQueueProvider.overrideWith(_MediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + cloudStrategyNamesProvider.overrideWithValue(const { + 'strategy-needs-review': 'Haven retake', + }), + appUpdateStatusProvider.overrideWith( + (ref) async => const UpdateCheckResult( + isSupported: true, + isUpdateAvailable: true, + source: 'test', + ), + ), + desktopUpdateControllerProvider.overrideWithValue(null), + ]); +} + +Future _pump( + WidgetTester tester, + ProviderContainer container, + Widget banner, +) { + return tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ShadApp(home: Scaffold(body: banner)), + ), + ); +} + +class _Workspace extends LibraryWorkspaceNotifier { + _Workspace(this.workspace); + + final LibraryWorkspace workspace; + + @override + LibraryWorkspace build() => workspace; +} + +class _CloudSection extends CloudLibrarySectionNotifier { + _CloudSection(this.section); + + final CloudLibrarySection section; + + @override + CloudLibrarySection build() => section; +} + +class _ReadyAuth extends AuthProvider { + _ReadyAuth(this.ready); + + final bool ready; + + @override + AppAuthState build() => AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: ready, + convexAuthStatus: + ready ? ConvexAuthStatus.ready : ConvexAuthStatus.incident, + user: const User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _Queue extends StrategyOpQueueNotifier { + _Queue(this.initialState); + + final StrategyOpQueueState initialState; + final List discarded = []; + + @override + StrategyOpQueueState build() => initialState; + + @override + Future discardDeletedStrategy(String strategyPublicId) async { + discarded.add(strategyPublicId); + } +} + +class _MediaQueue extends CloudMediaUploadQueueNotifier { + final List cleared = []; + + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); + + @override + Future clearJobsForStrategy(String strategyPublicId) async { + cleared.add(strategyPublicId); + } +} diff --git a/test/widgets/cloud_sync_button_test.dart b/test/widgets/cloud_sync_button_test.dart new file mode 100644 index 00000000..a5e38be7 --- /dev/null +++ b/test/widgets/cloud_sync_button_test.dart @@ -0,0 +1,1752 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/cloud_media_models.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_models.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/cloud_sync_status_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/lineup_conflicts_provider.dart'; +import 'package:icarus/providers/collab/strategy_conflict_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/providers/text_draft_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/strategy/lineup_group_changes.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/client_upgrade_button.dart'; +import 'package:icarus/widgets/cloud_sync_button.dart'; +import 'package:icarus/widgets/editor_toolbar.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +class _CloudStrategyProvider extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); +} + +/// Counts saves instead of writing; the outbox's state is the test's. +class _SavingStrategyProvider extends _CloudStrategyProvider { + int saves = 0; + + @override + Future forceSaveNow(String id) async { + saves += 1; + } +} + +/// Answers whether the server accepts this build's protocol. +class _ProtocolRepository extends Fake implements ConvexStrategyRepository { + _ProtocolRepository({required this.accepts}); + + final bool accepts; + int asked = 0; + + @override + Future serverAcceptsCloudProtocol() async { + asked += 1; + return accepts; + } +} + +class _SettledOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + ); +} + +class _UnreliableOpQueue extends StrategyOpQueueNotifier { + @override + StrategyOpQueueState build() => const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + hasDurabilityFailure: true, + lastError: 'Cloud work could not be verified in the durable outbox. ' + 'Nothing was sent.', + ); +} + +class _EmptyMediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); +} + +class _FixedMediaQueue extends CloudMediaUploadQueueNotifier { + _FixedMediaQueue(this.initialState); + + final CloudMediaUploadQueueState initialState; + + @override + CloudMediaUploadQueueState build() => initialState; +} + +class _FixedOpQueue extends StrategyOpQueueNotifier { + _FixedOpQueue(this.initialState); + + final StrategyOpQueueState initialState; + + @override + StrategyOpQueueState build() => initialState; +} + +class _FixedSaveState extends StrategySaveStateNotifier { + _FixedSaveState(this.initialState); + + final StrategySaveState initialState; + + @override + StrategySaveState build() => initialState; +} + +class _AttentionOpQueue extends StrategyOpQueueNotifier { + _AttentionOpQueue( + this.rejectedCount, { + this.hasOtherStrategyAttention = false, + }); + + final int rejectedCount; + final bool hasOtherStrategyAttention; + int retryRejectedCount = 0; + int flushNowCount = 0; + + @override + StrategyOpQueueState build() => StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + attentionByEntityKey: { + for (var index = 0; index < rejectedCount; index++) + EntitySyncKey.element('page-1', 'element-$index'): + QueuedEntityIntent( + entityKey: EntitySyncKey.element('page-1', 'element-$index'), + pending: PendingOp( + op: ElementPatchOp( + opId: 'rejected-$index', + elementPublicId: 'element-$index', + pagePublicId: 'page-1', + payload: const {'value': 'mine'}, + expectedElementRevision: 1, + ), + clientId: 'client-a', + ), + ), + }, + accountOutbox: hasOtherStrategyAttention + ? const AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + ), + }, + ) + : const AccountStrategyOutboxSummary(), + lastError: 'Some saved work needs attention.', + ); + + @override + Future retryPaused({bool flushImmediately = true}) async {} + + @override + Future retryRejected({bool flushImmediately = true}) async { + retryRejectedCount += 1; + } + + @override + Future flushNow() async { + flushNowCount += 1; + } +} + +class _ConflictSession extends StrategyPageSessionNotifier { + _ConflictSession({this.result = true, this.failure, this.keepBoth}); + + final bool result; + final Object? failure; + + /// What Keep both answers; by default what [result] says. + final KeepBothOutcome? keepBoth; + int useCloudCount = 0; + int keepBothCount = 0; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + @override + Future useCloudVersionsForRejected() async { + useCloudCount += 1; + if (failure != null) throw failure!; + return result; + } + + @override + Future keepBothForRejected() async { + keepBothCount += 1; + if (failure != null) throw failure!; + return keepBoth ?? + (result ? KeepBothOutcome.kept : KeepBothOutcome.unchanged); + } +} + +class _DeletedPageSession extends StrategyPageSessionNotifier { + _DeletedPageSession({ + required this.leaves, + this.restore = DeletedPageRestore.restored, + }); + + final bool leaves; + final DeletedPageRestore restore; + int leaveCount = 0; + int restoreCount = 0; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + void teammateDeletes() => setStateForTest( + state.copyWith(deletedPage: (pageId: 'page-1', name: 'A exec')), + ); + + @override + Future leaveDeletedPage() async { + leaveCount += 1; + if (leaves) setStateForTest(state.copyWith(clearDeletedPage: true)); + return leaves; + } + + @override + Future restoreDeletedPage() async { + restoreCount += 1; + if (restore == DeletedPageRestore.restored) { + setStateForTest(state.copyWith(clearDeletedPage: true)); + } + return restore; + } +} + +class _FixedLiveSync extends ActivePageLiveSyncNotifier { + _FixedLiveSync(this.fixed); + + final ActivePageLiveSyncState fixed; + + @override + ActivePageLiveSyncState build() => fixed; +} + +ProviderContainer _createContainer({ + bool connected = true, + StrategyOpQueueState? opQueueState, + CloudMediaUploadQueueState? mediaQueueState, + StrategySaveState? saveState, + ActivePageLiveSyncState? liveSyncState, +}) { + return ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + if (liveSyncState != null) + activePageLiveSyncProvider.overrideWith( + () => _FixedLiveSync(liveSyncState), + ), + strategyOpQueueProvider.overrideWith( + opQueueState == null + ? _SettledOpQueue.new + : () => _FixedOpQueue(opQueueState), + ), + cloudMediaUploadQueueProvider.overrideWith( + mediaQueueState == null + ? _EmptyMediaQueue.new + : () => _FixedMediaQueue(mediaQueueState), + ), + convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + if (saveState != null) + strategySaveStateProvider.overrideWith( + () => _FixedSaveState(saveState), + ), + ], + ); +} + +ProviderContainer _createConflictContainer({ + required _AttentionOpQueue queue, + required _ConflictSession session, + List? lineupConflicts, +}) { + return ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(() => queue), + strategyPageSessionProvider.overrideWith(() => session), + lineupConflictsProvider.overrideWithValue(lineupConflicts), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + cloudMediaAccountIdProvider.overrideWithValue('account-a'), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ], + ); +} + +Finder _syncButton(String status) => + find.byKey(ValueKey('cloud-sync-button-$status')); + +/// The one-line meaning the button shows on hover. +String _syncTooltip(WidgetTester tester) { + final button = tester.widget( + find.byType(EditorToolbarButton), + ); + return button.tooltip; +} + +void main() { + test('restored active-strategy media renders as syncing', () { + final container = _createContainer( + mediaQueueState: CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'restored-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'restored-image', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.pendingUpload, + referenceDurable: false, + attempts: 0, + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ), + ); + addTearDown(container.dispose); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.syncing); + expect( + container.read(cloudSyncStatusProvider), + isNot(CloudSyncStatus.synced), + ); + }); + + test('restored active-strategy media errors remain visible offline', + () async { + final container = _createContainer( + connected: false, + mediaQueueState: CloudMediaUploadQueueState( + jobs: [ + CloudMediaUploadJob( + jobId: 'missing-image', + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + assetPublicId: 'missing-image', + fileExtension: 'png', + mimeType: 'image/png', + state: CloudMediaJobState.failed, + attempts: 1, + lastError: 'Local media file is missing.', + updatedAt: DateTime.utc(2026), + ), + ], + isProcessing: false, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + + testWidgets('an active text draft can never appear synced', (tester) async { + final container = _createContainer(); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + + expect(_syncButton('synced'), findsOneWidget); + + container + .read(textDraftProvider.notifier) + .setDraft('text-1', 'visible local edit'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(_syncButton('editing'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); + + expect(_syncTooltip(tester), 'Edit not synced yet'); + }); + + testWidgets('offline remains visible while a text draft is active', + (tester) async { + final container = _createContainer(connected: false); + addTearDown(container.dispose); + container + .read(textDraftProvider.notifier) + .setDraft('text-1', 'offline edit'); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('editing'), findsNothing); + expect(_syncButton('synced'), findsNothing); + }); + + test('status provider prioritizes connectivity over an offline flush error', + () async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: true, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: 'Cloud connection is offline.', + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.offline); + }); + + test('real queue attention remains visible while offline', () async { + const entityKey = EntitySyncKey.strategy(); + const intent = QueuedEntityIntent( + entityKey: entityKey, + pending: PendingOp( + op: StrategyPatchOp( + opId: 'conflicted-op', + payload: {'name': 'conflicted'}, + expectedStrategyRevision: 1, + ), + clientId: 'client-a', + ), + ); + final container = _createContainer( + connected: false, + opQueueState: StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + attentionByEntityKey: { + entityKey: intent, + }, + durableLoaded: true, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + + test('queued work in another strategy prevents a synced status', () { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 1, + inFlightCount: 0, + pausedCount: 0, + attentionCount: 0, + successorCount: 0, + ), + }, + ), + ), + ); + addTearDown(container.dispose); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.syncing); + }); + + testWidgets('inactive attention directs the user to the cloud library', + (tester) async { + final container = _createContainer( + opQueueState: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + accountOutbox: AccountStrategyOutboxSummary( + accountId: 'account-a', + strategies: { + 'closed-strategy': StrategyOutboxSummary( + strategyPublicId: 'closed-strategy', + queuedCount: 0, + inFlightCount: 0, + pausedCount: 1, + attentionCount: 0, + successorCount: 0, + reason: 'Retry limit reached', + ), + }, + ), + ), + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + + expect(_syncButton('attention'), findsOneWidget); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Saved work in another strategy needs attention. Open it from the ' + 'Cloud library to review the reason.', + ), + findsOneWidget, + ); + expect(find.text('Retry sync'), findsNothing); + }); + + test('media errors remain visible while offline', () async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: false, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: null, + hasPendingMediaSync: true, + mediaSyncErrorCount: 1, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect(container.read(cloudSyncStatusProvider), CloudSyncStatus.attention); + }); + + testWidgets('offline flush errors render Offline instead of Needs attention', + (tester) async { + final container = _createContainer( + connected: false, + saveState: const StrategySaveState( + isDirty: true, + isSaving: false, + hasPendingCloudSync: true, + cloudSyncError: 'Cloud connection is offline.', + hasPendingMediaSync: false, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(_syncButton('offline'), findsOneWidget); + expect(_syncButton('attention'), findsNothing); + }); + + testWidgets('durability uncertainty never appears synced or reliable', + (tester) async { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(_UnreliableOpQueue.new), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + + final queue = container.read(strategyOpQueueProvider); + expect(queue.outboxIsReliable, isFalse); + expect(_syncButton('attention'), findsOneWidget); + expect(_syncButton('synced'), findsNothing); + + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + expect(find.text('Retry sync'), findsOneWidget); + expect(find.textContaining('safely stored'), findsNothing); + }); + + group('when the server needs a newer Icarus', () { + ProviderContainer upgradeContainer({ + bool updateWaiting = false, + StrategyOpQueueState? queue, + _SavingStrategyProvider? strategy, + _ProtocolRepository? repository, + void Function()? onUpdateCheck, + }) { + final container = ProviderContainer( + overrides: [ + strategyProvider.overrideWith( + strategy == null ? _CloudStrategyProvider.new : () => strategy, + ), + strategyOpQueueProvider.overrideWith( + queue == null ? _SettledOpQueue.new : () => _FixedOpQueue(queue), + ), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + authProvider.overrideWith(_ReadyAuthProvider.new), + convexConnectionSnapshotProvider.overrideWithValue(true), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + appUpdateStatusProvider.overrideWith((ref) async { + onUpdateCheck?.call(); + return UpdateCheckResult( + isSupported: true, + isUpdateAvailable: updateWaiting, + source: 'test', + ); + }), + desktopUpdateControllerProvider.overrideWithValue(null), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + ], + ); + container.read(clientUpgradeRequiredProvider.notifier).noteError( + clientUpgradeRequiredQueueError, + ); + return container; + } + + Future pump( + WidgetTester tester, + ProviderContainer container, + Widget child, + ) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ToastificationWrapper( + child: ShadApp(home: Scaffold(body: child)), + ), + ), + ); + await tester.pump(); + } + + Future openPopover( + WidgetTester tester, + ProviderContainer container, + ) async { + await pump( + tester, + container, + const CloudSyncButton(style: kEditorToolbarButtonStyle), + ); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + } + + test('nothing appears synced, even with nothing queued', () async { + final container = upgradeContainer(); + addTearDown(container.dispose); + await container.read(convexConnectionProvider.future); + + expect( + container.read(cloudSyncStatusProvider), + CloudSyncStatus.attention, + ); + }); + + testWidgets('says so and offers the update in place of a retry', + (tester) async { + final container = upgradeContainer(updateWaiting: true); + addTearDown(container.dispose); + + await openPopover(tester, container); + + expect( + find.text('Icarus was updated. Install the update to keep syncing.'), + findsOneWidget, + ); + expect(find.text('Update'), findsOneWidget); + expect(find.text('Retry sync'), findsNothing); + expect(find.text('Keep mine'), findsNothing); + }); + + test('looks for the update again once refused', () async { + var checks = 0; + final container = ProviderContainer(); + addTearDown(container.dispose); + final previous = UpdateChecker.windowsStoreCheckOverride; + UpdateChecker.windowsStoreCheckOverride = () async { + checks += 1; + return { + 'isSupported': true, + 'isUpdateAvailable': false, + }; + }; + addTearDown(() => UpdateChecker.windowsStoreCheckOverride = previous); + + await container.read(appUpdateStatusProvider.future); + expect(checks, 1); + container + .read(clientUpgradeRequiredProvider.notifier) + .noteError(clientUpgradeRequiredQueueError); + await container.read(appUpdateStatusProvider.future); + + expect(checks, 2); + }); + + testWidgets( + 'says honestly when no update is there yet, and can check again', + (tester) async { + var checks = 0; + final repository = _ProtocolRepository(accepts: true); + final container = upgradeContainer( + repository: repository, + onUpdateCheck: () => checks += 1, + ); + addTearDown(container.dispose); + + await openPopover(tester, container); + + expect( + find.textContaining("the update isn't available for this app yet"), + findsOneWidget, + ); + expect(find.textContaining('Your saved work stays on this device'), + findsOneWidget); + expect(find.textContaining('Install the update'), findsNothing); + expect(find.text('Retry sync'), findsNothing); + final checksBefore = checks; + + await tester.tap(find.text('Check again')); + await tester.pumpAndSettle(); + + // The update is looked for again, and the server asked whether it + // accepts this build again (a rolled-back deploy), which it does. + expect(checks, greaterThan(checksBefore)); + expect(repository.asked, 1); + expect(container.read(clientUpgradeRequiredProvider), isFalse); + }); + + testWidgets('unsaved work outranks the refusal and is never reloaded away', + (tester) async { + final container = upgradeContainer( + updateWaiting: true, + queue: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + hasDurabilityFailure: true, + lastError: 'Cloud work could not be verified in the durable ' + 'outbox. Nothing was sent.', + ), + ); + addTearDown(container.dispose); + + await openPopover(tester, container); + + expect(find.text('Retry sync'), findsOneWidget); + expect(find.textContaining('Icarus was updated'), findsNothing); + expect(find.byKey(const ValueKey('client-upgrade-button')), findsNothing); + }); + + testWidgets('the web reloads when no work is waiting to be kept', + (tester) async { + final strategy = _SavingStrategyProvider(); + final container = upgradeContainer(strategy: strategy); + addTearDown(container.dispose); + var reloads = 0; + + await pump( + tester, + container, + ClientUpgradeNotice( + isWeb: true, + onReload: () => reloads += 1, + builder: (context, message, action) => + Column(children: [Text(message), action!]), + ), + ); + expect(find.text('Icarus was updated. Reload to keep syncing.'), + findsOneWidget); + await tester.tap(find.text('Reload')); + await tester.pumpAndSettle(); + + expect(reloads, 1); + }); + + testWidgets('reload asks first while the outbox is uncertain', + (tester) async { + final strategy = _SavingStrategyProvider(); + final container = upgradeContainer( + strategy: strategy, + queue: const StrategyOpQueueState( + accountId: 'account-a', + strategyPublicId: 'cloud-strategy', + clientId: 'client-a', + durableLoaded: true, + hasDurabilityFailure: true, + ), + ); + addTearDown(container.dispose); + var reloads = 0; + + await pump( + tester, + container, + ClientUpgradeNotice( + isWeb: true, + onReload: () => reloads += 1, + builder: (context, message, action) => + Column(children: [Text(message), action!]), + ), + ); + await tester.tap(find.text('Reload')); + await tester.pumpAndSettle(); + + // The same guard as leaving the strategy: it can't confirm the work + // is kept on this device, so it offers to stay, never to leave. + expect(reloads, 0); + expect(find.text('Cloud sync pending'), findsOneWidget); + expect(find.text('Leave anyway'), findsNothing); + await tester.tap(find.text('Stay here')); + await tester.pumpAndSettle(); + }); + }); + + testWidgets('conflict popover offers an explicit cloud choice', + (tester) async { + final queue = _AttentionOpQueue(2); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect( + find.textContaining('applies to all 2 conflicting changes'), + findsOneWidget, + ); + // An element conflict has no lineup changes to list and nothing to keep + // beside the cloud's version. + expect(find.text('Keep both'), findsNothing); + expect(find.text('Your changes'), findsNothing); + + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect(session.useCloudCount, 1); + expect(queue.retryRejectedCount, 0); + expect(queue.flushNowCount, 0); + }); + + group('lineup conflicts', () { + LineupChange change(String label, String description) => + LineupChange(label: label, description: description); + + final conflicts = [ + LineupGroupConflict( + key: const EntitySyncKey.lineup('page-1', 'link-a'), + yours: [change('Heaven', 'notes edited')], + cloud: [ + change('Mid', 'renamed'), + change('Short', 'added'), + change('Long', 'landing moved'), + change('Deep', 'deleted'), + change('Sova lineup', 'images changed'), + ], + ), + ]; + + Future openPopover( + WidgetTester tester, ProviderContainer container) async { + // The test font sets every glyph a full em wide, so the lists wrap far + // more than in the app; a desktop-sized window gives them the room. + tester.view.physicalSize = const Size(1280, 1024); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold( + body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + } + + Finder line(String text) => find.text(text, findRichText: true); + + testWidgets( + 'without the version both sides started from, list one section of ' + 'how yours differs from the cloud', (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + lineupConflicts: [ + LineupGroupConflict( + key: const EntitySyncKey.lineup('page-1', 'link-a'), + yours: [ + change('Heaven', 'notes edited'), + change('Mid', 'notes edited'), + ], + cloud: null, + ), + // One conflict without that version is enough to show none of + // the sides' changes apart. + LineupGroupConflict( + key: const EntitySyncKey.lineup('page-1', 'link-z'), + yours: [change('Long', 'landing moved')], + cloud: [change('Short', 'added')], + ), + ], + ); + addTearDown(container.dispose); + await openPopover(tester, container); + + expect( + find.text('How your version differs from the cloud'), findsOneWidget); + expect(line('Heaven · notes edited'), findsOneWidget); + expect(line('Mid · notes edited'), findsOneWidget); + expect(line('Long · landing moved'), findsOneWidget); + expect(find.text('Your changes'), findsNothing); + expect(find.text('Cloud changes'), findsNothing); + expect(line('Short · added'), findsNothing); + expect(find.text('Keep both'), findsOneWidget); + }); + + testWidgets('list what each side changed and offer Keep both', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + lineupConflicts: conflicts, + ); + addTearDown(container.dispose); + await openPopover(tester, container); + + expect( + find.textContaining('A teammate changed these lineups'), + findsOneWidget, + ); + expect(find.text('Your changes'), findsOneWidget); + expect(line('Heaven · notes edited'), findsOneWidget); + expect(find.text('Cloud changes'), findsOneWidget); + expect(line('Mid · renamed'), findsOneWidget); + expect(line('Short · added'), findsOneWidget); + expect(line('Long · landing moved'), findsOneWidget); + expect(line('Deep · deleted'), findsOneWidget); + // Past four lines the rest are counted. + expect(line('Sova lineup · images changed'), findsNothing); + expect(find.text('and 1 more lineup'), findsOneWidget); + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect(find.text('Keep both'), findsOneWidget); + + await tester.tap(find.text('Keep both')); + await tester.pumpAndSettle(); + + expect(session.keepBothCount, 1); + expect(session.useCloudCount, 0); + expect(queue.retryRejectedCount, 0); + expect(queue.flushNowCount, 0); + }); + + testWidgets('a Keep both that cannot load the cloud version says so', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(result: false); + final container = _createConflictContainer( + queue: queue, + session: session, + lineupConflicts: conflicts, + ); + addTearDown(container.dispose); + await openPopover(tester, container); + + await tester.tap(find.text('Keep both')); + await tester.pumpAndSettle(); + + expect(session.keepBothCount, 1); + expect( + find.text('Could not load the cloud version. Nothing was changed.'), + findsOneWidget, + ); + expect(find.text('Keep both'), findsOneWidget); + }); + + testWidgets( + 'a Keep both that copied but could not resolve says the conflict ' + 'is still open', (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(keepBoth: KeepBothOutcome.copiesOnly); + final container = _createConflictContainer( + queue: queue, + session: session, + lineupConflicts: conflicts, + ); + addTearDown(container.dispose); + await openPopover(tester, container); + + await tester.tap(find.text('Keep both')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Your version was added as a copy'), + findsOneWidget, + ); + expect(find.textContaining('the conflict is still open'), findsOneWidget); + }); + }); + + testWidgets( + 'active conflict controls remain when another strategy needs attention', + (tester) async { + final queue = _AttentionOpQueue( + 1, + hasOtherStrategyAttention: true, + ); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + expect(find.textContaining('Choose which version to keep'), findsOneWidget); + expect( + find.textContaining('another strategy also needs attention'), + findsOneWidget, + ); + expect(find.textContaining('Cloud library'), findsOneWidget); + }); + + testWidgets('keep mine remains an explicit rejected retry', (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + + await tester.tap(find.text('Keep mine')); + await tester.pumpAndSettle(); + + expect(queue.retryRejectedCount, 1); + expect(queue.flushNowCount, 1); + expect(session.useCloudCount, 0); + }); + + testWidgets('oversized saved work shows its durable attention reason', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + container + .read(strategySaveStateProvider.notifier) + .setCloudSyncError(cloudOperationTooLargeMessage); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('too large for cloud sync'), + findsOneWidget, + ); + expect(find.textContaining('Another edit reached'), findsNothing); + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + }); + + for (final (name, reason, expected) in [ + ( + 'another page', + lineupPageMismatchMessage, + 'clashes with one on another page', + ), + ( + 'its old cloud format', + retiredLineupOpMessage, + 'saved by an older version of Icarus', + ), + ]) { + testWidgets('a lineup refused for $name says why, not "another edit"', + (tester) async { + final queue = _AttentionOpQueue(1); + final container = _createConflictContainer( + queue: queue, + session: _ConflictSession(), + ); + addTearDown(container.dispose); + container + .read(strategySaveStateProvider.notifier) + .setCloudSyncError(reason); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold( + body: CloudSyncButton(style: kEditorToolbarButtonStyle), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.textContaining(expected), findsOneWidget); + expect(find.textContaining('Another edit reached'), findsNothing); + expect(find.textContaining('Choose which version'), findsNothing); + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), findsOneWidget); + }); + } + + testWidgets( + 'a lineup group the server refused as overlapping another says why, ' + 'not that a teammate changed it', (tester) async { + tester.view.physicalSize = const Size(1280, 1024); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final queue = _AttentionOpQueue(1); + final container = _createConflictContainer( + queue: queue, + session: _ConflictSession(), + // Even with lineup changes to list, the specific reason comes first. + lineupConflicts: const [ + LineupGroupConflict( + key: EntitySyncKey.lineup('page-1', 'link-a'), + yours: [LineupChange(label: 'Heaven', description: 'added')], + cloud: [], + ), + ], + ); + addTearDown(container.dispose); + container + .read(strategySaveStateProvider.notifier) + .setCloudSyncError(lineupOverlapMessage); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('shares a spot with lineups saved separately'), + findsOneWidget, + ); + expect( + find.textContaining('Keep mine replaces their changes'), findsNothing); + expect( + find.textContaining('A teammate changed these lineups'), findsNothing); + expect(find.textContaining('Another edit reached'), findsNothing); + expect(find.text('Your changes'), findsNothing); + expect(find.text('Keep both'), findsNothing); + expect(find.text('Use cloud'), findsOneWidget); + }); + + for (final (name, error, keepMine) in [ + ('alone', lineupOverlapMessage, true), + ( + 'beside other work', + '$lineupOverlapMessage. $otherWorkNeedsAttentionNote', + true, + ), + ]) { + testWidgets( + 'an overlap refusal $name still offers Keep mine, saying when it ' + 'helps', (tester) async { + final queue = _AttentionOpQueue(keepMine ? 2 : 1); + final container = _createConflictContainer( + queue: queue, + session: _ConflictSession(), + ); + addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).setCloudSyncError( + error, + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold( + body: CloudSyncButton(style: kEditorToolbarButtonStyle), + ), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('shares a spot with lineups saved separately'), + findsOneWidget, + ); + expect( + find.textContaining('refused while the spot is still shared'), + findsOneWidget, + ); + expect(find.text('Use cloud'), findsOneWidget); + expect(find.text('Keep mine'), keepMine ? findsOneWidget : findsNothing); + expect(find.text('Retry sync'), findsNothing); + expect(find.text('Keep both'), findsNothing); + }); + } + + testWidgets( + 'a lineup refusal among several changes says the choice covers all ' + 'of them', (tester) async { + final queue = _AttentionOpQueue(2); + final container = _createConflictContainer( + queue: queue, + session: _ConflictSession(), + ); + addTearDown(container.dispose); + container + .read(strategySaveStateProvider.notifier) + .setCloudSyncError(lineupPageMismatchMessage); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.textContaining('another page'), findsOneWidget); + expect( + find.textContaining('applies to all 2 changes that need attention'), + findsOneWidget, + ); + }); + + testWidgets('a lineup refusal beside other refused work mentions both', + (tester) async { + final queue = _AttentionOpQueue(2); + final container = _createConflictContainer( + queue: queue, + session: _ConflictSession(), + ); + addTearDown(container.dispose); + container.read(strategySaveStateProvider.notifier).setCloudSyncError( + '$lineupPageMismatchMessage. $otherWorkNeedsAttentionNote', + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + + expect(find.textContaining('another page'), findsOneWidget); + expect( + find.textContaining('Other changes here were not saved either'), + findsOneWidget, + ); + expect(find.textContaining('Another edit reached'), findsNothing); + expect( + find.textContaining('applies to all 2 changes that need attention'), + findsOneWidget, + ); + }); + + group('refusal toast', () { + Finder toast(String text) => find.byWidgetPredicate( + (widget) => widget is Text && widget.data == text, + skipOffstage: false, + ); + + Future pumpButton(WidgetTester tester) async { + final container = _createConflictContainer( + queue: _AttentionOpQueue(1), + session: _ConflictSession(), + ); + addTearDown(container.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: CloudSyncButton(style: kEditorToolbarButtonStyle), + ), + ), + ), + ), + ); + await tester.pump(); + return container; + } + + Future drainToasts(WidgetTester tester) async { + toastification.dismissAll(delayForAnimation: false); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + } + + testWidgets('a lineup refusal toasts its own reason', (tester) async { + final container = await pumpButton(tester); + + container.read(strategyConflictProvider.notifier).push( + const ConflictResolution( + type: ConflictResolutionType.rebase, + opId: 'refused-link', + message: lineupPageMismatchMessage, + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + toast(friendlyCloudSyncError(lineupPageMismatchMessage)), + findsOneWidget, + ); + expect( + find.textContaining('Another edit reached', skipOffstage: false), + findsNothing, + ); + await drainToasts(tester); + }); + + testWidgets('an edit that lost a race still toasts as a conflict', + (tester) async { + final container = await pumpButton(tester); + + container.read(strategyConflictProvider.notifier).push( + const ConflictResolution( + type: ConflictResolutionType.rebase, + opId: 'stale-edit', + message: 'revision_mismatch', + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.textContaining('Another edit reached', skipOffstage: false), + findsOneWidget, + ); + await drainToasts(tester); + }); + }); + + testWidgets('failed cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(result: false); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); + + testWidgets('thrown cloud load keeps attention and explains the failure', + (tester) async { + final queue = _AttentionOpQueue(1); + final session = _ConflictSession(failure: StateError('refresh failed')); + final container = _createConflictContainer( + queue: queue, + session: session, + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: + Scaffold(body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + await tester.tap(_syncButton('attention')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Use cloud')); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Could not load the cloud version. Your saved version was not changed.', + ), + findsOneWidget, + ); + expect( + container.read(strategyOpQueueProvider).attentionByEntityKey, + hasLength(1), + ); + }); + + test('an image still uploading keeps the unsaved mark', () { + final container = _createContainer( + saveState: const StrategySaveState( + isDirty: true, + isSaving: false, + hasPendingCloudSync: false, + cloudSyncError: null, + hasPendingMediaSync: true, + mediaSyncErrorCount: 0, + lastPersistedAt: null, + ), + ); + addTearDown(container.dispose); + + container.read(strategySaveStateProvider.notifier).clearStaleCloudMark(); + + expect(container.read(strategySaveStateProvider).isDirty, isTrue); + }); + + group('unsaved work on a page a teammate deleted', () { + Future<_DeletedPageSession> pumpButton( + WidgetTester tester, { + required bool leaves, + bool deletedBeforeMount = false, + DeletedPageRestore restore = DeletedPageRestore.restored, + }) async { + final session = _DeletedPageSession(leaves: leaves, restore: restore); + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_CloudStrategyProvider.new), + strategyOpQueueProvider.overrideWith(_SettledOpQueue.new), + strategyPageSessionProvider.overrideWith(() => session), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(true)), + ]); + addTearDown(container.dispose); + if (deletedBeforeMount) { + container.read(strategyPageSessionProvider); + session.teammateDeletes(); + } + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold( + body: CloudSyncButton(style: kEditorToolbarButtonStyle)), + ), + ), + ); + await tester.pump(); + if (!deletedBeforeMount) session.teammateDeletes(); + await tester.pumpAndSettle(); + return session; + } + + testWidgets('says so until the user has read it', (tester) async { + final session = await pumpButton(tester, leaves: true); + + expect(find.text('This page was deleted'), findsOneWidget); + expect( + find.textContaining('“A exec” was deleted while you were editing it'), + findsOneWidget, + ); + // Only reading it closes it. + await tester.tapAt(const Offset(5, 5)); + await tester.pumpAndSettle(); + expect(find.text('This page was deleted'), findsOneWidget); + + await tester.tap(find.text('Discard changes')); + await tester.pumpAndSettle(); + expect(session.leaveCount, 1); + expect(session.restoreCount, 0); + expect(find.text('This page was deleted'), findsNothing); + }); + + testWidgets('restoring the page keeps the work and closes the notice', + (tester) async { + final session = await pumpButton(tester, leaves: true); + expect(find.text('Restore page'), findsOneWidget); + + await tester.tap(find.text('Restore page')); + await tester.pumpAndSettle(); + + expect(session.restoreCount, 1); + expect(session.leaveCount, 0); + expect(find.text('This page was deleted'), findsNothing); + }); + + testWidgets( + 'a page that can no longer be restored says so and leaves discard', + (tester) async { + final session = await pumpButton( + tester, + leaves: true, + restore: DeletedPageRestore.gone, + ); + + await tester.tap(find.text('Restore page')); + await tester.pumpAndSettle(); + + expect( + find.textContaining( + '“A exec” can no longer be restored, so your latest changes'), + findsOneWidget, + ); + expect(find.text('Restore page'), findsNothing); + await tester.tap(find.text('Discard changes')); + await tester.pumpAndSettle(); + expect(session.leaveCount, 1); + expect(find.text('This page was deleted'), findsNothing); + }); + + testWidgets('a restore that fails says so and keeps both choices', + (tester) async { + final session = await pumpButton( + tester, + leaves: true, + restore: DeletedPageRestore.failed, + ); + + await tester.tap(find.text('Restore page')); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Could not confirm the page was restored'), + findsOneWidget, + ); + expect(find.text('This page was deleted'), findsOneWidget); + expect(find.text('Restore page'), findsOneWidget); + await tester.tap(find.text('Discard changes')); + await tester.pumpAndSettle(); + expect(session.leaveCount, 1); + expect(find.text('This page was deleted'), findsNothing); + }); + + testWidgets('shows when the button mounts after the page was deleted', + (tester) async { + await pumpButton(tester, leaves: true, deletedBeforeMount: true); + + expect(find.text('This page was deleted'), findsOneWidget); + }); + + testWidgets('stays while changes are still being sent', (tester) async { + final session = await pumpButton(tester, leaves: false); + + await tester.tap(find.text('Discard changes')); + await tester.pumpAndSettle(); + + expect(session.leaveCount, 1); + expect( + find.textContaining('Could not leave this page yet'), + findsOneWidget, + ); + expect(find.text('This page was deleted'), findsOneWidget); + }); + }); +} + +class _ReadyAuthProvider extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, + ); +} diff --git a/test/widgets/confirm_alert_dialog_semantics_test.dart b/test/widgets/confirm_alert_dialog_semantics_test.dart new file mode 100644 index 00000000..99060a50 --- /dev/null +++ b/test/widgets/confirm_alert_dialog_semantics_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/dialogs/confirm_alert_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('confirmation actions expose stable tappable semantics', + (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + const ShadApp( + home: Scaffold( + body: ConfirmAlertDialog( + title: 'Sign out?', + content: 'Cloud work stays online.', + confirmText: 'Sign Out', + ), + ), + ), + ); + + expect( + tester.getSemantics(find.byKey(const ValueKey('confirm-alert-cancel'))), + matchesSemantics(label: 'Cancel', isButton: true, hasTapAction: true), + ); + expect( + tester.getSemantics(find.byKey(const ValueKey('confirm-alert-confirm'))), + matchesSemantics(label: 'Sign Out', isButton: true, hasTapAction: true), + ); + semantics.dispose(); + }); +} diff --git a/test/widgets/delete_page_dialog_test.dart b/test/widgets/delete_page_dialog_test.dart new file mode 100644 index 00000000..74575eec --- /dev/null +++ b/test/widgets/delete_page_dialog_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/dialogs/delete_page_dialog.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _FixedPresence extends StrategyPresenceNotifier { + _FixedPresence(this._state); + + final PresenceRoomState _state; + + @override + PresenceRoomState build() => _state; +} + +class _Strategy extends StrategyProvider { + _Strategy(this.source); + + final StrategySource source; + + @override + StrategyState build() => StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: source, + isOpen: true, + ); +} + +PresencePeer _peer(String uid, String name, String? pageId) => PresencePeer( + sid: 'sid-$uid', + uid: uid, + name: name, + avatarUrl: null, + role: 'editor', + cursor: + pageId == null ? null : PresenceCursor(pageId: pageId, x: 0, y: 0), + ); + +PresenceRoomState _room(List peers) => PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: {for (final peer in peers) peer.sid: peer}, + ); + +void main() { + /// Opens the delete confirmation for page-1 and returns its answer once + /// [answer] (a button label) is tapped. + Future confirm( + WidgetTester tester, + PresenceRoomState room, { + required String expectContent, + required String answer, + StrategySource source = StrategySource.cloud, + }) async { + bool? confirmed; + await tester.pumpWidget(ProviderScope( + overrides: [ + strategyPresenceProvider.overrideWith(() => _FixedPresence(room)), + strategyProvider.overrideWith(() => _Strategy(source)), + ], + child: ShadApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => Center( + child: ShadButton( + onPressed: () async => confirmed = await confirmDeletePage( + context, + ref, + pageId: 'page-1', + pageName: 'A exec', + ), + child: const Text('open'), + ), + ), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + expect(find.text("Delete 'A exec'?"), findsOneWidget); + expect(find.text(expectContent), findsOneWidget); + await tester.tap(find.text(answer)); + await tester.pumpAndSettle(); + return confirmed; + } + + const plain = 'Are you sure you want to delete this page? You can restore ' + 'it from Recently deleted for 30 days.'; + + testWidgets( + 'a local page is gone for good, and the dialog says so, as it always has', + (tester) async { + expect( + await confirm( + tester, + _room(const []), + expectContent: 'Are you sure you want to delete this page? This ' + 'action cannot be undone.', + answer: 'Delete', + source: StrategySource.local, + ), + isTrue, + ); + }); + + testWidgets( + 'with nobody else on the page, asks plainly and says where to restore ' + 'it', (tester) async { + final room = _room([ + _peer('alex', 'Alex', 'page-2'), + _peer('sam', 'Sam', null), + // Your own other window is not a teammate. + _peer('me', 'Me', 'page-1'), + ]); + + expect( + await confirm(tester, room, expectContent: plain, answer: 'Delete'), + isTrue, + ); + expect(find.text('Delete anyway'), findsNothing); + }); + + testWidgets('says who is on the page, and deleting anyway still deletes', + (tester) async { + final room = _room([_peer('alex', 'Alex', 'page-1')]); + + expect( + await confirm( + tester, + room, + expectContent: 'Alex is on this page right now. Delete it anyway? ' + 'You can restore it from Recently deleted for 30 days.', + answer: 'Delete anyway', + ), + isTrue, + ); + }); + + testWidgets('each name is bold, in its cursor colour on the map', + (tester) async { + final room = _room([ + _peer('alex', 'Alex', 'page-1'), + _peer('sam', 'Sam', 'page-1'), + ]); + await tester.pumpWidget(ProviderScope( + overrides: [ + strategyPresenceProvider.overrideWith(() => _FixedPresence(room)), + strategyProvider.overrideWith(() => _Strategy(StrategySource.cloud)), + ], + child: ShadApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => Center( + child: ShadButton( + onPressed: () => confirmDeletePage(context, ref, + pageId: 'page-1', pageName: 'A exec'), + child: const Text('open'), + ), + ), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + final text = tester.widget(find.textContaining('on this page')); + final spans = {}; + text.textSpan!.visitChildren((span) { + if (span is TextSpan && span.text != null) spans[span.text!] = span.style; + return true; + }); + for (final (uid, name) in [('alex', 'Alex'), ('sam', 'Sam')]) { + expect(spans[name]?.fontWeight, FontWeight.bold); + expect(spans[name]?.color, presenceColorFor(uid)); + } + expect(spans[' and ']?.fontWeight, isNull); + }); + + testWidgets('cancelling a warned delete keeps the page', (tester) async { + final room = _room([ + _peer('alex', 'Alex', 'page-1'), + _peer('sam', 'Sam', 'page-1'), + ]); + + expect( + await confirm( + tester, + room, + expectContent: 'Alex and Sam are on this page right now. Delete it ' + 'anyway? You can restore it from Recently deleted for 30 days.', + answer: 'Cancel', + ), + isFalse, + ); + }); + + testWidgets('a crowd is counted', (tester) async { + final room = _room([ + _peer('alex', 'Alex', 'page-1'), + _peer('sam', 'Sam', 'page-1'), + _peer('kim', 'Kim', 'page-1'), + _peer('lee', 'Lee', 'page-1'), + ]); + + await confirm( + tester, + room, + expectContent: 'Alex, Sam and 2 others are on this page right now. ' + 'Delete it anyway? You can restore it from Recently deleted for 30 ' + 'days.', + answer: 'Cancel', + ); + }); +} diff --git a/test/widgets/pages_bar_recently_deleted_test.dart b/test/widgets/pages_bar_recently_deleted_test.dart new file mode 100644 index 00000000..4b8a6522 --- /dev/null +++ b/test/widgets/pages_bar_recently_deleted_test.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/trashed_pages_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/pages_bar.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +final _at = DateTime.utc(2026, 9, 29); + +RemotePage _page(String id, int sortIndex) => RemotePage( + publicId: id, + strategyPublicId: 'strategy-a', + name: 'Page ${sortIndex + 1}', + sortIndex: sortIndex, + isAttack: true, + revision: 1, + createdAt: _at, + updatedAt: _at, + ); + +class _Remote extends RemoteEditorSnapshotNotifier { + /// The strategy's revision moves, as a delete or restore moves it. + void moveRevision(int revision) => state = AsyncData(RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy-a', + name: 'Strategy A', + mapData: 'ascent', + revision: revision, + createdAt: _at, + updatedAt: _at, + ), + pages: [_page('page-1', 0), _page('page-2', 1)], + ), + activePage: null, + )); + + @override + Future build() async => RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy-a', + name: 'Strategy A', + mapData: 'ascent', + revision: 3, + createdAt: _at, + updatedAt: _at, + ), + pages: [_page('page-1', 0), _page('page-2', 1)], + ), + activePage: null, + ); +} + +class _CloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.cloud, + isOpen: true, + ); +} + +class _Session extends StrategyPageSessionNotifier { + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-1', + availablePageIds: ['page-1', 'page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); +} + +class _Preferences extends AppPreferencesNotifier { + @override + AppPreferences build() => + AppPreferences(defaultThemeProfileIdForNewStrategies: 'default'); +} + +/// Serves [pages] as the strategy's trash. +class _Trash implements ConvexStrategyRepository { + _Trash([this.pages = const []]); + + List pages; + final List listed = []; + + /// Reads fail while set, as offline. + bool offline = false; + + @override + Future> listTrashedPages(String strategyPublicId) async { + listed.add(strategyPublicId); + if (offline) throw StateError('Cloud connection is offline.'); + return pages; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +StrategyCapabilities _caps({required bool canDeletePage}) => + StrategyCapabilities( + canRenameStrategy: false, + canDeleteStrategy: false, + canDuplicateStrategy: false, + canMoveStrategy: false, + canEditPages: canDeletePage, + canAddPage: canDeletePage, + canRenamePage: canDeletePage, + canDeletePage: canDeletePage, + canReorderPages: canDeletePage, + canCreateFolder: false, + canEditFolder: false, + canDeleteFolder: false, + canMoveFolder: false, + ); + +Future _expandedBar( + WidgetTester tester, { + required bool canDeletePage, + ConvexStrategyRepository? repository, + _Remote? remote, +}) async { + await tester.pumpWidget(ProviderScope( + overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote ?? _Remote()), + strategyProvider.overrideWith(_CloudStrategy.new), + strategyPageSessionProvider.overrideWith(_Session.new), + appPreferencesProvider.overrideWith(_Preferences.new), + currentStrategyCapabilitiesProvider + .overrideWithValue(_caps(canDeletePage: canDeletePage)), + if (repository != null) + convexStrategyRepositoryProvider.overrideWithValue(repository), + ], + child: const ShadApp( + home: Scaffold(body: Center(child: PagesBar())), + ), + )); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.chevronDown)); + await tester.pumpAndSettle(); + expect(find.text('Page 2'), findsOneWidget); +} + +void main() { + testWidgets( + 'an editor opens Recently deleted from the pages bar when it has pages', + (tester) async { + final repository = _Trash([ + TrashedPage( + pageId: 'page-3', + name: 'Retake B', + deletedAt: _at, + restorableUntil: _at.add(const Duration(days: 30)), + deletedByName: 'Sam', + deletedByYou: false, + ), + ]); + await _expandedBar(tester, canDeletePage: true, repository: repository); + + await tester.tap(find.byIcon(LucideIcons.archiveRestore)); + await tester.pumpAndSettle(); + + expect(find.text('Retake B'), findsOneWidget); + }); + + testWidgets('an empty trash shows no Recently deleted button', + (tester) async { + final repository = _Trash(); + await _expandedBar(tester, canDeletePage: true, repository: repository); + + expect(repository.listed, ['strategy-a']); + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + }); + + testWidgets('someone who cannot delete pages has no Recently deleted', + (tester) async { + await _expandedBar(tester, canDeletePage: false); + + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + }); + + testWidgets( + 'the button comes with the first deleted page and goes with the ' + 'last', (tester) async { + final repository = _Trash(); + final remote = _Remote(); + await _expandedBar(tester, + canDeletePage: true, repository: repository, remote: remote); + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + + repository.pages = [ + TrashedPage( + pageId: 'page-3', + name: 'Retake B', + deletedAt: _at, + restorableUntil: _at.add(const Duration(days: 30)), + deletedByName: null, + deletedByYou: true, + ), + ]; + remote.moveRevision(4); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.archiveRestore), findsOneWidget); + + repository.pages = []; + remote.moveRevision(5); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + }); + + testWidgets('a failed read is tried again, so the button comes back online', + (tester) async { + final repository = _Trash([ + TrashedPage( + pageId: 'page-3', + name: 'Retake B', + deletedAt: _at, + restorableUntil: _at.add(const Duration(days: 30)), + deletedByName: 'Sam', + deletedByYou: false, + ), + ]) + ..offline = true; + await _expandedBar(tester, canDeletePage: true, repository: repository); + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + + // Back online; nothing about the strategy changed. + repository.offline = false; + await tester.pump(trashRecheckDelay); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.archiveRestore), findsOneWidget); + }); + + testWidgets("the button goes when the last page's 30 days run out", + (tester) async { + final soon = DateTime.now().add(const Duration(minutes: 1)); + final repository = _Trash([ + TrashedPage( + pageId: 'page-3', + name: 'Retake B', + deletedAt: soon.subtract(const Duration(days: 30)), + restorableUntil: soon, + deletedByName: 'Sam', + deletedByYou: false, + ), + ]); + await _expandedBar(tester, canDeletePage: true, repository: repository); + expect(find.byIcon(LucideIcons.archiveRestore), findsOneWidget); + + // The server lists only restorable pages; once past, it lists none. + repository.pages = []; + await tester.pump(const Duration(minutes: 1, seconds: 1)); + await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.archiveRestore), findsNothing); + }); +} diff --git a/test/widgets/pages_bar_undo_delete_test.dart b/test/widgets/pages_bar_undo_delete_test.dart new file mode 100644 index 00000000..b455ed39 --- /dev/null +++ b/test/widgets/pages_bar_undo_delete_test.dart @@ -0,0 +1,384 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/const/transition_data.dart'; +import 'package:icarus/providers/collab/remote_strategy_snapshot_provider.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/pages_bar.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +final _at = DateTime.utc(2026, 9, 29); + +RemotePage _page(String id, int sortIndex) => RemotePage( + publicId: id, + strategyPublicId: 'strategy-a', + name: 'Page ${sortIndex + 1}', + sortIndex: sortIndex, + isAttack: true, + revision: 1, + createdAt: _at, + updatedAt: _at, + ); + +RemoteEditorSnapshot _snapshot(List pages) => RemoteEditorSnapshot( + shell: RemoteStrategyShell( + header: RemoteStrategyHeader( + publicId: 'strategy-a', + name: 'Strategy A', + mapData: 'ascent', + revision: 3, + createdAt: _at, + updatedAt: _at, + ), + pages: pages, + ), + activePage: null, + ); + +/// The server's pages. A refresh waits on [refreshGate] and then lists +/// [afterRefresh]. +class _Remote extends RemoteEditorSnapshotNotifier { + Completer? refreshGate; + List afterRefresh = [_page('page-1', 0), _page('page-2', 1)]; + + @override + Future build() async => + _snapshot([_page('page-1', 0), _page('page-2', 1)]); + + @override + Future refresh() async { + await refreshGate?.future; + state = AsyncData(_snapshot(afterRefresh)); + } +} + +/// A cloud strategy whose deletes the server accepts, after [deleteGate]. +class _CloudStrategy extends StrategyProvider { + final List deleted = []; + Completer? deleteGate; + + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-a', + strategyName: 'Strategy A', + source: StrategySource.cloud, + isOpen: true, + ); + + @override + Future deletePage(String pageId) async { + deleted.add(pageId); + await deleteGate?.future; + return true; + } + + void switchTo(String strategyId) => + state = state.copyWith(strategyId: strategyId); +} + +class _Session extends StrategyPageSessionNotifier { + final List restored = []; + final List opened = []; + bool openFails = false; + + /// A restore waits on this, then answers [restoreOutcome]. + Completer? restoreGate; + DeletedPageRestore restoreOutcome = DeletedPageRestore.restored; + + /// Runs while a page is opening, before it fails or lands. + void Function()? duringOpen; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-2', + availablePageIds: ['page-1', 'page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + @override + Future restorePageFromTrash(String pageId) async { + restored.add(pageId); + await restoreGate?.future; + return restoreOutcome; + } + + @override + Future setActivePageAnimated( + String pageId, { + required PageTransitionDirection direction, + Duration duration = Duration.zero, + }) async { + duringOpen?.call(); + if (openFails) throw StateError('Could not load the page.'); + opened.add(pageId); + } +} + +class _Preferences extends AppPreferencesNotifier { + @override + AppPreferences build() => + AppPreferences(defaultThemeProfileIdForNewStrategies: 'default'); +} + +const _caps = StrategyCapabilities( + canRenameStrategy: false, + canDeleteStrategy: false, + canDuplicateStrategy: false, + canMoveStrategy: false, + canEditPages: true, + canAddPage: true, + canRenamePage: true, + canDeletePage: true, + canReorderPages: true, + canCreateFolder: false, + canEditFolder: false, + canDeleteFolder: false, + canMoveFolder: false, +); + +class _Harness { + final strategy = _CloudStrategy(); + final session = _Session(); + final remote = _Remote(); + final showBar = ValueNotifier(true); + + Future pump(WidgetTester tester) async { + await tester.pumpWidget(ProviderScope( + overrides: [ + remoteEditorSnapshotProvider.overrideWith(() => remote), + strategyProvider.overrideWith(() => strategy), + strategyPageSessionProvider.overrideWith(() => session), + appPreferencesProvider.overrideWith(_Preferences.new), + currentStrategyCapabilitiesProvider.overrideWithValue(_caps), + ], + child: ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: Center( + child: ValueListenableBuilder( + valueListenable: showBar, + builder: (_, show, __) => + show ? const PagesBar() : const SizedBox.shrink(), + ), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.chevronDown)); + await tester.pumpAndSettle(); + } + + /// Opens Page 2's delete confirmation (the page you are on) and confirms. + Future confirmDelete(WidgetTester tester) async { + await tester.tap(find.byIcon(LucideIcons.trash)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('confirm-alert-confirm'))); + } +} + +Future<_Harness> _deleted(WidgetTester tester) async { + final harness = _Harness(); + await harness.pump(tester); + await harness.confirmDelete(tester); + await tester.pumpAndSettle(); + expect(harness.strategy.deleted, ['page-2']); + expect(find.text("Deleted 'Page 2'"), findsOneWidget); + return harness; +} + +/// Result toasts close on a timer, which must not outlive the test. +Future _letToastsClose(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); +} + +void main() { + tearDown(() => toastification.dismissAll(delayForAnimation: false)); + + testWidgets('Undo after a cloud delete restores the page and goes back to it', + (tester) async { + final harness = await _deleted(tester); + + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect(harness.session.restored, ['page-2']); + expect(harness.session.opened, ['page-2']); + expect(find.text('Undo'), findsNothing); + }); + + testWidgets('a second tap on Undo restores nothing more', (tester) async { + final harness = await _deleted(tester); + + await tester.tap(find.text('Undo')); + await tester.tap(find.text('Undo'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(harness.session.restored, ['page-2']); + }); + + testWidgets('leaving the strategy takes the Undo offer away', (tester) async { + final harness = await _deleted(tester); + + harness.showBar.value = false; + await tester.pumpAndSettle(); + expect(find.text('Undo'), findsNothing); + expect(harness.session.restored, isEmpty); + }); + + testWidgets('Undo does nothing once another strategy is open', + (tester) async { + final harness = await _deleted(tester); + + harness.strategy.switchTo('strategy-b'); + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect(harness.session.restored, isEmpty); + }); + + testWidgets('switching strategies while the delete lands offers no Undo', + (tester) async { + final harness = _Harness(); + harness.strategy.deleteGate = Completer(); + await harness.pump(tester); + await harness.confirmDelete(tester); + await tester.pump(); + + harness.strategy.switchTo('strategy-b'); + harness.strategy.deleteGate!.complete(); + await tester.pumpAndSettle(); + expect(harness.strategy.deleted, ['page-2']); + expect(find.text('Undo'), findsNothing); + }); + + testWidgets('leaving while Undo is refreshing the list is safe', + (tester) async { + final harness = await _deleted(tester); + harness.remote.refreshGate = Completer(); + + await tester.tap(find.text('Undo')); + await tester.pump(); + expect(harness.session.restored, ['page-2']); + harness.showBar.value = false; + await tester.pumpAndSettle(); + harness.remote.refreshGate!.complete(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(harness.session.opened, isEmpty); + }); + + testWidgets('a restore the list does not show yet says only that', + (tester) async { + final harness = await _deleted(tester); + harness.remote.afterRefresh = [_page('page-1', 0)]; + + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect(find.text("Restored 'Page 2'."), findsOneWidget); + expect(harness.session.opened, isEmpty); + await _letToastsClose(tester); + }); + + testWidgets('a restored page that will not open says so', (tester) async { + final harness = await _deleted(tester); + harness.session.openFails = true; + + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect( + find.text("Restored 'Page 2', but could not open it. It is in the " + 'pages list.'), + findsOneWidget, + ); + await _letToastsClose(tester); + }); + + testWidgets('switching strategies with the confirmation open deletes nothing', + (tester) async { + final harness = _Harness(); + await harness.pump(tester); + await tester.tap(find.byIcon(LucideIcons.trash)); + await tester.pumpAndSettle(); + + harness.strategy.switchTo('strategy-b'); + await tester.tap(find.byKey(const ValueKey('confirm-alert-confirm'))); + await tester.pumpAndSettle(); + expect(harness.strategy.deleted, isEmpty); + expect(find.text('Undo'), findsNothing); + }); + + testWidgets('a page deleted again while opening is not claimed as listed', + (tester) async { + final harness = await _deleted(tester); + harness.session + ..openFails = true + ..duringOpen = () => + harness.remote.state = AsyncData(_snapshot([_page('page-1', 0)])); + + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect( + find.text("Restored 'Page 2', but could not open it."), findsOneWidget); + await _letToastsClose(tester); + }); + + testWidgets('a failed restore after leaving names the strategy it was in', + (tester) async { + final harness = await _deleted(tester); + harness.session + ..restoreGate = Completer() + ..restoreOutcome = DeletedPageRestore.failed; + + await tester.tap(find.text('Undo')); + await tester.pump(); + harness.strategy.switchTo('strategy-b'); + harness.session.restoreGate!.complete(); + await tester.pumpAndSettle(); + expect( + find.text("Could not confirm that 'Page 2' was restored in Strategy A. " + "Once you're connected, check the pages list or that strategy's " + 'Recently deleted.'), + findsOneWidget, + ); + await _letToastsClose(tester); + }); + + testWidgets('a page that can no longer come back says so', (tester) async { + final harness = await _deleted(tester); + harness.session.restoreOutcome = DeletedPageRestore.gone; + + await tester.tap(find.text('Undo')); + await tester.pumpAndSettle(); + expect(find.text("'Page 2' can no longer be restored."), findsOneWidget); + await _letToastsClose(tester); + }); + + test('Undo failure messages', () { + expect( + undoFailedMessage('Page 2', DeletedPageRestore.failed, + strategyName: null), + "Could not confirm that 'Page 2' was restored. Once you're connected, " + 'check the pages list or Recently deleted.', + ); + expect( + undoFailedMessage('Page 2', DeletedPageRestore.failed, strategyName: ''), + "Could not confirm that 'Page 2' was restored. Once you're connected, " + "check the pages list or its strategy's Recently deleted.", + ); + expect( + undoFailedMessage('Page 2', DeletedPageRestore.gone, + strategyName: 'Strategy A'), + "'Page 2' can no longer be restored.", + ); + }); +} diff --git a/test/widgets/recently_deleted_dialog_test.dart b/test/widgets/recently_deleted_dialog_test.dart new file mode 100644 index 00000000..75b3ed13 --- /dev/null +++ b/test/widgets/recently_deleted_dialog_test.dart @@ -0,0 +1,290 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/widgets/dialogs/recently_deleted_dialog.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +final _now = DateTime.now(); + +TrashedPage _trashed( + String pageId, + String name, { + Duration ago = const Duration(days: 2), + String? by, + bool byYou = false, +}) => + TrashedPage( + pageId: pageId, + name: name, + deletedAt: _now.subtract(ago), + restorableUntil: _now.subtract(ago).add(const Duration(days: 30)), + deletedByName: by, + deletedByYou: byYou, + ); + +/// Serves [pages] as the server's trash, or fails while [offline]. +class _TrashRepository implements ConvexStrategyRepository { + _TrashRepository(this.pages); + + List pages; + bool offline = false; + final List listed = []; + + @override + Future> listTrashedPages(String strategyPublicId) async { + listed.add(strategyPublicId); + if (offline) throw StateError('Cloud connection is offline.'); + return pages; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +/// Answers each restore with the next of [outcomes], once [gate] opens. +class _Session extends StrategyPageSessionNotifier { + _Session(this.outcomes); + + final List outcomes; + final List restored = []; + Completer? gate; + + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'page-2', + availablePageIds: ['page-2'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); + + @override + Future restorePageFromTrash(String pageId) async { + restored.add(pageId); + await gate?.future; + return outcomes.removeAt(0); + } +} + +Future _open( + WidgetTester tester, + _TrashRepository repository, + _Session session, +) async { + await tester.pumpWidget(ProviderScope( + overrides: [ + convexStrategyRepositoryProvider.overrideWithValue(repository), + strategyPageSessionProvider.overrideWith(() => session), + ], + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => Center( + child: ShadButton( + onPressed: () => + RecentlyDeletedDialog.show(context, 'strategy-a'), + child: const Text('open'), + ), + ), + ), + ), + ), + )); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +void main() { + group('labels', () { + final now = DateTime(2026, 9, 29, 12); + TrashedPage page({String? by, bool byYou = false, Duration? ago}) => + TrashedPage( + pageId: 'p', + name: 'A exec', + deletedAt: now.subtract(ago ?? const Duration(days: 2)), + restorableUntil: now + .subtract(ago ?? const Duration(days: 2)) + .add(const Duration(days: 30)), + deletedByName: by, + deletedByYou: byYou, + ); + + test('say who deleted the page and when, if known', () { + expect(deletedLabel(page(by: 'Sam'), now), 'Deleted by Sam, 2 days ago'); + expect( + deletedLabel( + page(by: 'Sam', byYou: true, ago: const Duration(minutes: 5)), now), + 'Deleted by you, 5 minutes ago', + ); + expect( + deletedLabel(page(ago: const Duration(hours: 1)), now), + 'Deleted 1 hour ago', + ); + expect(deletedLabel(page(ago: Duration.zero), now), 'Deleted just now'); + }); + + test('count the days left', () { + expect(timeLeftLabel(page(), now), '28 days left'); + expect( + timeLeftLabel(page(ago: const Duration(days: 28, hours: 12)), now), + '1 day left', + ); + expect( + timeLeftLabel(page(ago: const Duration(days: 29, hours: 12)), now), + 'Less than a day left', + ); + }); + }); + + testWidgets('lists the deleted pages with who deleted each and when', + (tester) async { + // The list reads the clock; pin it to the instant the fixtures were + // made from, or a slow run drifts across an "hours ago" boundary. + await withClock(Clock.fixed(_now), () async { + final repository = _TrashRepository([ + _trashed('page-3', 'Retake', ago: const Duration(hours: 3), by: 'Sam'), + // Off a day boundary, as the list reads the clock a moment later. + _trashed('page-1', 'A exec', ago: const Duration(hours: 47)), + ]); + await _open(tester, repository, _Session([])); + + expect(repository.listed, ['strategy-a']); + expect(find.text('Recently deleted'), findsOneWidget); + expect(find.text('Retake'), findsOneWidget); + expect(find.text('Deleted by Sam, 3 hours ago · 29 days left'), + findsOneWidget); + expect(find.text('A exec'), findsOneWidget); + expect(find.text('Deleted 1 day ago · 28 days left'), findsOneWidget); + expect(find.text('Restore'), findsNWidgets(2)); + }); + }); + + testWidgets('says when nothing was deleted', (tester) async { + await _open(tester, _TrashRepository([]), _Session([])); + + expect(find.text('No deleted pages.'), findsOneWidget); + expect(find.text('Restore'), findsNothing); + }); + + testWidgets('says when the list cannot load, and tries again', + (tester) async { + final repository = _TrashRepository([_trashed('page-1', 'A exec')]) + ..offline = true; + await _open(tester, repository, _Session([])); + + expect( + find.text('Could not load deleted pages. Check your connection and try ' + 'again.'), + findsOneWidget, + ); + repository.offline = false; + await tester.tap(find.text('Try again')); + await tester.pumpAndSettle(); + expect(find.text('A exec'), findsOneWidget); + }); + + testWidgets('restoring a page takes it off the list', (tester) async { + final session = _Session([DeletedPageRestore.restored]) + ..gate = Completer(); + await _open( + tester, + _TrashRepository([ + _trashed('page-3', 'Retake'), + _trashed('page-1', 'A exec'), + ]), + session, + ); + + await tester.tap(find.text('Restore').last); + await tester.pump(); + expect(find.text('Restoring…'), findsOneWidget); + session.gate!.complete(); + await tester.pumpAndSettle(); + + expect(session.restored, ['page-1']); + expect(find.text('A exec'), findsNothing); + expect(find.text('Retake'), findsOneWidget); + }); + + testWidgets('a failed restore says so and can be tried again', + (tester) async { + final session = + _Session([DeletedPageRestore.failed, DeletedPageRestore.restored]); + await _open( + tester, _TrashRepository([_trashed('page-1', 'A exec')]), session); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + expect( + find.text( + 'Could not confirm the restore. Check your connection and try again.', + ), + findsOneWidget, + ); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + expect(session.restored, ['page-1', 'page-1']); + expect(find.text('No deleted pages.'), findsOneWidget); + }); + + testWidgets('a page past its time says it can no longer be restored', + (tester) async { + final session = _Session([DeletedPageRestore.gone]); + await _open( + tester, _TrashRepository([_trashed('page-1', 'A exec')]), session); + + await tester.tap(find.text('Restore')); + await tester.pumpAndSettle(); + + expect(find.text('It can no longer be restored.'), findsOneWidget); + expect(find.text('Restore'), findsNothing); + }); + + testWidgets( + 'a page whose time ran out while the list was open offers no Restore', + (tester) async { + final session = _Session([]); + await _open( + tester, + _TrashRepository([ + _trashed('page-1', 'A exec', ago: const Duration(days: 30, minutes: 1)), + ]), + session, + ); + + expect(find.text('It can no longer be restored.'), findsOneWidget); + expect(find.text('Deleted 30 days ago'), findsOneWidget); + expect(find.text('Restore'), findsNothing); + }); + + testWidgets("Restore goes away when a page's time runs out while it is open", + (tester) async { + final deletedAt = clock.now().subtract(const Duration(days: 30)); + await _open( + tester, + _TrashRepository([ + TrashedPage( + pageId: 'page-1', + name: 'A exec', + deletedAt: deletedAt, + restorableUntil: clock.now().add(const Duration(seconds: 30)), + deletedByName: null, + deletedByYou: false, + ), + ]), + _Session([]), + ); + expect(find.text('Restore'), findsOneWidget); + + await tester.pump(const Duration(minutes: 1)); + + expect(find.text('Restore'), findsNothing); + expect(find.text('It can no longer be restored.'), findsOneWidget); + }); +} diff --git a/test/widgets/settings_sign_out_test.dart b/test/widgets/settings_sign_out_test.dart new file mode 100644 index 00000000..0e57e61c --- /dev/null +++ b/test/widgets/settings_sign_out_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/services/guarded_sign_out.dart'; +import 'package:icarus/widgets/settings_tab.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +void main() { + testWidgets('Settings account action uses guarded sign out', (tester) async { + var requests = 0; + await tester.pumpWidget( + ProviderScope( + overrides: [ + authProvider.overrideWith(_SignedInAuth.new), + guardedSignOutRequestProvider.overrideWithValue((context) async { + requests += 1; + return true; + }), + ], + child: const ShadApp( + home: Scaffold(body: AccountSettingsSection()), + ), + ), + ); + + await tester.tap(find.byKey(const ValueKey('settings-sign-out'))); + await tester.pump(); + expect(requests, 1); + }); + + testWidgets('a build the server refuses asks for an update, not a reconnect', + (tester) async { + final container = ProviderContainer( + overrides: [ + authProvider.overrideWith(_IncidentAuth.new), + appUpdateStatusProvider.overrideWith( + (ref) async => const UpdateCheckResult( + isSupported: true, + isUpdateAvailable: false, + source: 'test', + ), + ), + desktopUpdateControllerProvider.overrideWithValue(null), + ], + ); + addTearDown(container.dispose); + container + .read(clientUpgradeRequiredProvider.notifier) + .noteError(clientUpgradeRequiredQueueError); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const ShadApp( + home: Scaffold(body: AccountSettingsSection()), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Cloud sync needs a newer Icarus'), findsOneWidget); + expect(find.text('Reconnect'), findsNothing); + expect(find.text('Check again'), findsOneWidget); + }); +} + +class _IncidentAuth extends _SignedInAuth { + @override + AppAuthState build() => + super.build().copyWith(convexAuthStatus: ConvexAuthStatus.incident); +} + +class _SignedInAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {'full_name': 'Coach'}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} diff --git a/test/widgets/strategy_presence_test.dart b/test/widgets/strategy_presence_test.dart new file mode 100644 index 00000000..92a85711 --- /dev/null +++ b/test/widgets/strategy_presence_test.dart @@ -0,0 +1,290 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/const/agents.dart'; +import 'package:icarus/const/line_provider.dart'; +import 'package:icarus/const/placed_classes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/lineup_editing_presence_provider.dart'; +import 'package:icarus/providers/collab/strategy_presence_provider.dart'; +import 'package:icarus/providers/strategy_page_session_provider.dart'; +import 'package:icarus/widgets/dialogs/lineup_panel_dialog.dart'; +import 'package:icarus/widgets/lineup_editors_notice.dart'; +import 'package:icarus/widgets/strategy_presence.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +class _FixedPresence extends StrategyPresenceNotifier { + _FixedPresence(this._state); + + final PresenceRoomState _state; + + @override + PresenceRoomState build() => _state; +} + +PresencePeer _peer( + String sid, + String uid, + String name, + String role, { + PresenceEditing? editing, +}) => + PresencePeer( + sid: sid, + uid: uid, + name: name, + avatarUrl: null, + role: role, + cursor: null, + editing: editing, + ); + +/// The editor showing page p1, without the session's cloud machinery. +class _FixedSession extends StrategyPageSessionNotifier { + @override + StrategyPageSessionState build() => const StrategyPageSessionState( + activePageId: 'p1', + availablePageIds: ['p1'], + transitionState: PageTransitionState.idle, + isApplyingPage: false, + ); +} + +/// Two lineups from two origins landing on one spot, all lineup group g1. +LineUpGraph _landingGraph() { + LineUpOrigin origin(String id) => LineUpOrigin( + id: id, + agent: PlacedAgent( + id: '$id-agent', + type: AgentType.breach, + position: const Offset(20, 20), + ), + ); + return LineUpGraph( + origins: [origin('origin-1'), origin('origin-2')], + landings: [ + LineUpLanding( + id: 'landing', + ability: PlacedAbility( + id: 'landing-ability', + data: AgentData.agents[AgentType.breach]!.abilities.first, + position: const Offset(50, 50), + ), + ), + ], + links: [ + LineUpLink(id: 'link-1', originId: 'origin-1', landingId: 'landing'), + LineUpLink(id: 'link-2', originId: 'origin-2', landingId: 'landing'), + ], + ); +} + +/// An editor on page p1 whose landing spot opens the lineup panel. +ProviderContainer _panelContainer(PresenceRoomState presence) { + final container = ProviderContainer(overrides: [ + strategyPresenceProvider.overrideWith(() => _FixedPresence(presence)), + strategyPageSessionProvider.overrideWith(_FixedSession.new), + ]); + container.read(lineUpProvider.notifier).fromHive(_landingGraph()); + container.read(activePageLiveSyncProvider.notifier).noteLineupGroups('p1', { + 'origin-1': 'g1', + 'origin-2': 'g1', + 'landing': 'g1', + 'link-1': 'g1', + 'link-2': 'g1', + }); + return container; +} + +Future _openPanel( + WidgetTester tester, ProviderContainer container) async { + tester.view.physicalSize = const Size(1400, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(UncontrolledProviderScope( + container: container, + child: ShadApp( + home: Scaffold( + body: Builder( + builder: (context) => ShadButton( + onPressed: () => showLineUpPanel(context, landingId: 'landing'), + child: const Text('Open panel'), + ), + ), + ), + ), + )); + await tester.tap(find.text('Open panel')); + await tester.pumpAndSettle(); + expect(find.byType(LineUpPanelDialog), findsOneWidget); +} + +Widget _app(PresenceRoomState state, Widget child) => ProviderScope( + overrides: [ + strategyPresenceProvider.overrideWith(() => _FixedPresence(state)), + ], + child: ShadApp(home: Scaffold(body: Center(child: child))), + ); + +void main() { + testWidgets('shows nothing when you are alone', (tester) async { + await tester.pumpWidget(_app( + const PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + ), + const StrategyPresenceAvatars(), + )); + expect( + find.byKey(const ValueKey('strategy-presence-avatars')), findsNothing); + }); + + testWidgets('hovering an avatar names the person and says when they view', + (tester) async { + await tester.pumpWidget(_app( + PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + 's1': _peer('s1', 'ben', 'Ben', 'viewer'), + 's2': _peer('s2', 'me', 'Me elsewhere', 'owner'), + }, + ), + const StrategyPresenceAvatars(), + )); + + final avatars = find.byKey(const ValueKey('strategy-presence-avatars')); + expect(avatars, findsOneWidget); + // One person: your own other window is not someone else. + expect(tester.getSize(avatars).width, 24); + + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + await mouse.addPointer(location: Offset.zero); + addTearDown(mouse.removePointer); + await mouse.moveTo(tester.getCenter(avatars)); + await tester.pumpAndSettle(); + expect(find.text('Ben (viewing)'), findsOneWidget); + }); + + testWidgets('more than four people collapse into a count', (tester) async { + await tester.pumpWidget(_app( + PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + for (var i = 0; i < 6; i++) + 's$i': _peer('s$i', 'u$i', 'Player $i', 'editor'), + }, + ), + const StrategyPresenceAvatars(), + )); + expect(find.text('+2'), findsOneWidget); + }); + + test('a person has one color everywhere', () { + expect(presenceColorFor('0123456789abcdef01234567'), + presenceColorFor('0123456789abcdef01234567')); + // Pinned so a change to the hash (which would recolor everyone) is + // deliberate. + expect( + [ + for (final uid in ['a', 'b', 'c', 'd', 'e']) presenceColorFor(uid) + ].toSet(), + hasLength(Settings.presenceColors.length), + ); + }); + + group('lineUpEditorsText', () { + final sam = _peer('s1', 'sam', 'Sam', 'editor'); + final ana = _peer('s2', 'ana', 'Ana', 'editor'); + final ben = _peer('s3', 'ben', 'Ben', 'editor'); + final cy = _peer('s4', 'cy', 'Cy', 'editor'); + + test('names one editor', () { + expect(lineUpEditorsText([sam]), 'Sam is editing this lineup right now.'); + expect(lineUpEditorsText([sam], lineups: true), + 'Sam is editing these lineups right now.'); + }); + + test('names two editors', () { + expect(lineUpEditorsText([sam, ana]), + 'Sam and Ana are editing this lineup right now.'); + expect(lineUpEditorsText([sam, ana], lineups: true), + 'Sam and Ana are editing these lineups right now.'); + }); + + test('counts the rest past one editor when there are three or more', () { + expect(lineUpEditorsText([sam, ana, ben]), + 'Sam and 2 others are editing this lineup right now.'); + expect(lineUpEditorsText([sam, ana, ben, cy], lineups: true), + 'Sam and 3 others are editing these lineups right now.'); + }); + }); + + group('lineup panel', () { + const sams = PresenceEditing(pageId: 'p1', groupIds: {'g1'}); + + testWidgets('says who else is editing the lineups of its spot', + (tester) async { + final container = _panelContainer(PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: {'s1': _peer('s1', 'sam', 'Sam', 'editor', editing: sams)}, + )); + addTearDown(container.dispose); + await _openPanel(tester, container); + expect( + find.text('Sam is editing these lineups right now.'), + findsOneWidget, + ); + }); + + testWidgets('says nothing when no one else edits its lineups', + (tester) async { + final container = _panelContainer(PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + peers: { + // Another group, the same group on another page, and you. + 's1': _peer('s1', 'sam', 'Sam', 'editor', + editing: const PresenceEditing(pageId: 'p1', groupIds: {'g2'})), + 's2': _peer('s2', 'ana', 'Ana', 'editor', + editing: const PresenceEditing(pageId: 'p2', groupIds: {'g1'})), + 's3': _peer('s3', 'me', 'Me elsewhere', 'editor', editing: sams), + 's4': _peer('s4', 'ben', 'Ben', 'editor'), + }, + )); + addTearDown(container.dispose); + await _openPanel(tester, container); + expect(find.byType(LineUpEditorsNotice), findsOneWidget); + expect(find.textContaining('editing'), findsNothing); + }); + + testWidgets('counts as editing its spot while it is open', (tester) async { + final container = _panelContainer(const PresenceRoomState( + connection: PresenceConnection.live, + selfUid: 'me', + )); + addTearDown(container.dispose); + expect(container.read(openLineUpItemsProvider), isEmpty); + + await _openPanel(tester, container); + expect(container.read(openLineUpItemsProvider).values, [ + {'landing'}, + ]); + expect( + container.read(myLineupEditingProvider), + const PresenceEditing(pageId: 'p1', groupIds: {'g1'}), + ); + + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + expect(find.byType(LineUpPanelDialog), findsNothing); + expect(container.read(openLineUpItemsProvider), isEmpty); + expect(container.read(myLineupEditingProvider), isNull); + }); + }); +} diff --git a/test/widgets/strategy_quick_switcher_layout_test.dart b/test/widgets/strategy_quick_switcher_layout_test.dart index 57361cf5..7dc66292 100644 --- a/test/widgets/strategy_quick_switcher_layout_test.dart +++ b/test/widgets/strategy_quick_switcher_layout_test.dart @@ -8,6 +8,7 @@ import 'package:hive_ce/hive.dart'; import 'package:icarus/const/hive_boxes.dart'; import 'package:icarus/const/settings.dart'; import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; import 'package:icarus/widgets/strategy_quick_switcher.dart'; import 'package:icarus/widgets/window_chrome.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -79,11 +80,11 @@ void main() { class _OpenStrategyProvider extends StrategyProvider { @override StrategyState build() { - return StrategyState( - isSaved: true, - stratName: 'afeaf', - id: 'strategy-1', - storageDirectory: null, + return const StrategyState( + strategyId: 'strategy-1', + strategyName: 'afeaf', + source: StrategySource.local, + isOpen: true, ); } } diff --git a/test/widgets/strategy_save_toast_test.dart b/test/widgets/strategy_save_toast_test.dart new file mode 100644 index 00000000..200cd74e --- /dev/null +++ b/test/widgets/strategy_save_toast_test.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/collab_models.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/collab/durable_strategy_outbox.dart'; +import 'package:icarus/const/app_provider_container.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/active_page_live_sync_provider.dart'; +import 'package:icarus/providers/collab/cloud_media_upload_queue_provider.dart'; +import 'package:icarus/providers/collab/convex_connection_provider.dart'; +import 'package:icarus/providers/collab/strategy_op_queue_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/providers/strategy_save_state_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/strategy_save_icon_button.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:toastification/toastification.dart'; + +/// The save toast after the press: it must say where the work actually is, +/// read from the library and the outboxes, not from the press. +void main() { + setUpAll(() { + appProviderContainer = ProviderContainer(); + }); + + tearDownAll(() => appProviderContainer.dispose()); + + group('cloud', () { + testWidgets('a failed outbox write is not called saved', (tester) async { + final container = _cloudContainer( + store: _FailingPutStore(), + connected: true, + ); + await _pressSave(tester, container); + + expect(find.text(unverifiedCloudWorkMessage), findsOneWidget); + expect(find.text('Saved'), findsNothing); + expect( + container.read(strategyOpQueueProvider).hasDurabilityFailure, isTrue); + await _finish(tester, container); + }); + + testWidgets('offline, the change is saved here and waits to sync', + (tester) async { + final store = MemoryDurableStrategyOutboxStore(); + final container = _cloudContainer(store: store, connected: false); + await _pressSave(tester, container); + + expect(find.text('Offline, changes stay on this device'), findsOneWidget); + expect(find.text('Saved'), findsNothing); + expect(store.values, hasLength(1)); + await _finish(tester, container); + }); + + testWidgets('once the server accepts the change, it is saved', + (tester) async { + final store = MemoryDurableStrategyOutboxStore(); + final repository = _AckingRepository(); + final container = _cloudContainer( + store: store, + connected: true, + repository: repository, + ); + await _pressSave(tester, container); + + expect(repository.batches, 1); + expect(store.values, isEmpty); + expect(find.text('Saved'), findsOneWidget); + await _finish(tester, container); + }); + }); + + group('local', () { + testWidgets('a save that throws is not called saved', (tester) async { + final container = _localContainer( + () => _LocalStrategy((_) async => throw StateError('disk full')), + ); + await _pressSave(tester, container); + + expect(find.text(saveFailedMessage), findsOneWidget); + expect(find.text('Saved'), findsNothing); + await _finish(tester, container); + }); + + testWidgets('a save that never wrote the library is not called saved', + (tester) async { + // saveToHive returns quietly when the strategy is missing from the + // library: nothing throws, and nothing was written. + final container = _localContainer( + () => _LocalStrategy((_) async {}), + ); + await _pressSave(tester, container); + + expect(find.text(saveFailedMessage), findsOneWidget); + expect(find.text('Saved'), findsNothing); + await _finish(tester, container); + }); + + testWidgets('a save that wrote the library is saved', (tester) async { + final container = _localContainer( + () => _LocalStrategy( + (ref) async => + ref.read(strategySaveStateProvider.notifier).markPersisted(), + ), + ); + await _pressSave(tester, container); + + expect(find.text('Saved'), findsOneWidget); + await _finish(tester, container); + }); + }); +} + +Future _pressSave( + WidgetTester tester, ProviderContainer container) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: ToastificationWrapper( + child: ShadApp( + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + onPressed: () => saveStrategyNow(context, ref), + child: const Text('Save now'), + ), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Save now')); + await tester.pumpAndSettle(); +} + +/// Lets the toast close and the queue's retry timers go with the container. +Future _finish(WidgetTester tester, ProviderContainer container) async { + await tester.pump(const Duration(seconds: 4)); + await tester.pumpWidget(const SizedBox.shrink()); + container.dispose(); + await tester.pump(const Duration(seconds: 1)); +} + +ProviderContainer _cloudContainer({ + required DurableStrategyOutboxStore store, + required bool connected, + ConvexStrategyRepository? repository, +}) { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(_CloudStrategy.new), + durableStrategyOutboxStoreProvider.overrideWithValue(store), + convexStrategyRepositoryProvider + .overrideWithValue(repository ?? _AckingRepository()), + authProvider.overrideWith(_ReadyAuth.new), + convexConnectionProvider.overrideWith((ref) => Stream.value(connected)), + convexConnectionSnapshotProvider.overrideWith((ref) => connected), + cloudMediaUploadQueueProvider.overrideWith(_EmptyMediaQueue.new), + activePageLiveSyncProvider.overrideWith(_IdleLiveSync.new), + ]); + container.read(strategySaveStateProvider); + container + .read(strategyOpQueueProvider.notifier) + .setActiveStrategy('cloud-strategy', accountId: 'account-a'); + return container; +} + +ProviderContainer _localContainer(_LocalStrategy Function() strategy) { + final container = ProviderContainer(overrides: [ + strategyProvider.overrideWith(strategy), + ]); + container.read(strategySaveStateProvider); + return container; +} + +/// Saves a cloud strategy the way the editor does: the change goes to the +/// outbox, then the outbox is flushed. +class _CloudStrategy extends StrategyProvider { + @override + StrategyState build() => const StrategyState( + strategyId: 'cloud-strategy', + strategyName: 'Cloud Strategy', + source: StrategySource.cloud, + storageDirectory: null, + isOpen: true, + ); + + @override + Future forceSaveNow(String id) async { + ref.read(strategySaveStateProvider.notifier) + ..markDirty() + ..setPendingCloudSync(true); + final queue = ref.read(strategyOpQueueProvider.notifier); + await queue.enqueue( + const ElementPatchOp( + opId: 'save-press', + elementPublicId: 'element-1', + pagePublicId: 'page-1', + payload: {'value': 'mine'}, + expectedElementRevision: 1, + ), + ); + await queue.flushNow(); + } +} + +class _LocalStrategy extends StrategyProvider { + _LocalStrategy(this.save); + + final Future Function(Ref ref) save; + + @override + StrategyState build() => const StrategyState( + strategyId: 'local-strategy', + strategyName: 'Local Strategy', + source: StrategySource.local, + storageDirectory: null, + isOpen: true, + ); + + @override + Future forceSaveNow(String id) => save(ref); +} + +class _FailingPutStore extends MemoryDurableStrategyOutboxStore { + @override + Future put(DurableOutboxRecord record) async { + throw StateError('disk write failed'); + } +} + +class _AckingRepository extends Fake implements ConvexStrategyRepository { + int batches = 0; + + @override + Future> applyBatch({ + required String strategyPublicId, + required String clientId, + required List ops, + String? accountSubject, + }) async { + batches += 1; + return [for (final op in ops) AppliedOpAck(opId: op.opId, revision: 2)]; + } +} + +class _ReadyAuth extends AuthProvider { + @override + AppAuthState build() => const AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: User( + id: 'account-a', + appMetadata: {}, + userMetadata: {}, + aud: 'authenticated', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ); +} + +class _EmptyMediaQueue extends CloudMediaUploadQueueNotifier { + @override + CloudMediaUploadQueueState build() => const CloudMediaUploadQueueState( + jobs: [], + isProcessing: false, + ); +} + +class _IdleLiveSync extends ActivePageLiveSyncNotifier { + @override + ActivePageLiveSyncState build() => const ActivePageLiveSyncState(); +} diff --git a/test/widgets/strategy_tile_actions_semantics_test.dart b/test/widgets/strategy_tile_actions_semantics_test.dart new file mode 100644 index 00000000..0119ca8a --- /dev/null +++ b/test/widgets/strategy_tile_actions_semantics_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus/widgets/strategy_tile/strategy_tile.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +void main() { + testWidgets('strategy actions expose a named tap action', (tester) async { + var pressed = false; + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + ShadApp( + home: Scaffold( + body: StrategyTileActionsButton( + strategyName: 'A Split', + onPressed: () => pressed = true, + ), + ), + ), + ); + + final finder = find.bySemanticsLabel('More actions for A Split'); + expect( + tester.getSemantics(finder), + matchesSemantics( + label: 'More actions for A Split', + isButton: true, + hasTapAction: true, + ), + ); + tester.semantics.tap(find.semantics.byLabel('More actions for A Split')); + await tester.pump(); + expect(pressed, isTrue); + semantics.dispose(); + }); + + testWidgets('strategy menu actions expose named tap actions', (tester) async { + var pressed = false; + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + ShadApp( + home: Scaffold( + body: StrategyTileMenuActionSemantics( + label: 'Export A Split', + enabled: true, + onPressed: () => pressed = true, + child: const Text('Export'), + ), + ), + ), + ); + + final finder = find.bySemanticsLabel('Export A Split'); + expect( + tester.getSemantics(finder), + matchesSemantics( + label: 'Export A Split', + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + ); + tester.semantics.tap(find.semantics.byLabel('Export A Split')); + await tester.pump(); + expect(pressed, isTrue); + semantics.dispose(); + }); +} diff --git a/test/widgets/strategy_title_rename_test.dart b/test/widgets/strategy_title_rename_test.dart new file mode 100644 index 00000000..3a9d263e --- /dev/null +++ b/test/widgets/strategy_title_rename_test.dart @@ -0,0 +1,158 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/collab/generated/generated.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/settings.dart'; +import 'package:icarus/providers/collab/strategy_capabilities_provider.dart'; +import 'package:icarus/providers/strategy_provider.dart'; +import 'package:icarus/strategy/strategy_page_models.dart'; +import 'package:icarus/widgets/strategy_quick_switcher.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +void main() { + late Directory hiveDirectory; + + setUpAll(() async { + hiveDirectory = await Directory.systemTemp.createTemp('icarus-title-'); + Hive.init(hiveDirectory.path); + await Hive.openBox(HiveBoxNames.strategiesBox); + }); + + tearDownAll(() async { + await Hive.close(); + await hiveDirectory.delete(recursive: true); + }); + + Future<_OpenCloudStrategy> pumpTitle( + WidgetTester tester, { + required String role, + Object? renameError, + }) async { + final strategy = _OpenCloudStrategy(renameError); + await tester.pumpWidget(ProviderScope( + overrides: [ + strategyProvider.overrideWith(() => strategy), + currentStrategyCapabilitiesProvider.overrideWithValue( + StrategyCapabilities.fromCloudRole(role), + ), + ], + child: ToastificationWrapper( + child: ShadApp( + themeMode: ThemeMode.dark, + darkTheme: ShadThemeData( + brightness: Brightness.dark, + colorScheme: Settings.tacticalVioletTheme, + ), + home: const Scaffold( + body: Align( + alignment: Alignment.topCenter, + child: SizedBox(height: 40, child: StrategyQuickSwitcher()), + ), + ), + ), + ), + )); + await tester.pump(); + return strategy; + } + + Finder toast(String text) => find.byWidgetPredicate( + (widget) => widget is Text && widget.data == text, + skipOffstage: false, + ); + + Future drainToasts(WidgetTester tester) async { + toastification.dismissAll(delayForAnimation: false); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + } + + testWidgets('a viewer cannot put the title into edit mode', (tester) async { + final strategy = await pumpTitle(tester, role: 'viewer'); + + await tester.tap(find.text('Original')); + await tester.pump(); + + expect(find.byType(EditableText), findsNothing); + expect(strategy.renameCalls, 0); + }); + + testWidgets( + 'a refused rename restores the name, leaves edit mode, says so once, ' + 'and is not submitted again', (tester) async { + final strategy = await pumpTitle( + tester, + role: 'editor', + renameError: const ConvexFunctionException( + code: ConvexErrorCode.forbidden, + rawCode: 'FORBIDDEN', + message: 'Forbidden', + ), + ); + + await tester.tap(find.text('Original')); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'Renamed'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(tester.takeException(), isNull, reason: 'handled, not rethrown'); + expect(strategy.renameCalls, 1); + expect(find.byType(EditableText), findsNothing); + expect(find.text('Original'), findsOneWidget); + expect(toast("You can't rename this strategy."), findsOneWidget); + + // Focus moving elsewhere afterwards does not submit it again. + await tester.tapAt(const Offset(5, 300)); + await tester.pump(); + expect(strategy.renameCalls, 1); + await drainToasts(tester); + }); + + testWidgets('an editor renames normally', (tester) async { + final strategy = await pumpTitle(tester, role: 'editor'); + + await tester.tap(find.text('Original')); + await tester.pump(); + await tester.enterText(find.byType(EditableText), 'Renamed'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + + expect(strategy.renameCalls, 1); + expect(find.text('Renamed'), findsOneWidget); + expect(find.byType(EditableText), findsNothing); + }); +} + +class _OpenCloudStrategy extends StrategyProvider { + _OpenCloudStrategy(this.renameError); + + final Object? renameError; + int renameCalls = 0; + + @override + StrategyState build() => const StrategyState( + strategyId: 'strategy-1', + strategyName: 'Original', + source: StrategySource.cloud, + isOpen: true, + ); + + @override + Future renameStrategy( + String strategyID, + String newName, { + StrategySource? source, + }) async { + renameCalls += 1; + final error = renameError; + if (error != null) throw error; + state = state.copyWith(strategyName: newName); + } +} diff --git a/test/widgets/web_beta_library_test.dart b/test/widgets/web_beta_library_test.dart new file mode 100644 index 00000000..61c001d3 --- /dev/null +++ b/test/widgets/web_beta_library_test.dart @@ -0,0 +1,585 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce_flutter/adapters.dart'; +import 'package:icarus/collab/cloud_library_models.dart'; +import 'package:icarus/collab/cloud_sync_error_message.dart'; +import 'package:icarus/collab/convex_strategy_repository.dart'; +import 'package:icarus/config/platform_policy.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/const/maps.dart'; +import 'package:icarus/const/update_checker.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/providers/auth_provider.dart'; +import 'package:icarus/providers/collab/client_upgrade_required_provider.dart'; +import 'package:icarus/providers/collab/remote_library_provider.dart'; +import 'package:icarus/providers/desktop_update_provider.dart'; +import 'package:icarus/providers/folder_provider.dart'; +import 'package:icarus/providers/library_workspace_provider.dart'; +import 'package:icarus/providers/update_status_provider.dart'; +import 'package:icarus/providers/user_preferences_provider.dart'; +import 'package:icarus/strategy/strategy_models.dart'; +import 'package:icarus/widgets/dialogs/auth/auth_dialog.dart'; +import 'package:icarus/widgets/dialogs/web_beta_dialog.dart'; +import 'package:icarus/widgets/folder_navigator.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:toastification/toastification.dart'; + +/// The library as a user reaches it: the real FolderNavigator, workspace, +/// navigation, and create dialogs. Only auth and the Convex repository are +/// stand-ins. +void main() { + late Directory tempDir; + + setUpAll(() => registerIcarusAdapters(Hive)); + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('icarus-web-beta-'); + Hive.init(tempDir.path); + final strategies = + await Hive.openBox(HiveBoxNames.strategiesBox); + final folders = await Hive.openBox(HiveBoxNames.foldersBox); + await Hive.openBox(HiveBoxNames.pinnedItemsBox); + await Hive.openBox(HiveBoxNames.strategyOutboxBox); + await Hive.openBox(HiveBoxNames.cloudMediaOutboxBox); + // A new strategy takes the default theme and preferences. + await Hive.openBox(HiveBoxNames.mapThemeProfilesBox); + await Hive.openBox(HiveBoxNames.appPreferencesBox); + await MapThemeProfilesProvider.bootstrap(); + // A strategy and folder already saved in this browser (or on this + // computer). + final local = _strategy('local-plan', 'Local Plan'); + await strategies.put(local.id, local); + final folder = Folder( + name: 'Local Folder', + id: 'local-folder', + dateCreated: DateTime(2026, 9, 1), + color: FolderColor.blue, + ); + await folders.put(folder.id, folder); + await strategies.flush(); + await folders.flush(); + }); + + tearDown(() async { + await Hive.close(); + await tempDir.delete(recursive: true); + }); + + /// The local library's keys and on-disk bytes, to prove nothing wrote to + /// it. Keys catch a put at once; the bytes catch it once flushed. Hive's + /// file IO is real, so it runs outside the widget test's fake clock. + Future> localLibrary(WidgetTester tester) async { + final strategies = Hive.box(HiveBoxNames.strategiesBox); + final folders = Hive.box(HiveBoxNames.foldersBox); + await tester.runAsync(() async { + await strategies.flush(); + await folders.flush(); + }); + return [ + strategies.keys.toList(), + folders.keys.toList(), + for (final name in [HiveBoxNames.strategiesBox, HiveBoxNames.foldersBox]) + File('${tempDir.path}/$name.hive').readAsBytesSync(), + ]; + } + + group('web beta', () { + testWidgets('signed out, My Library is one sign-in action', + (tester) async { + final before = await localLibrary(tester); + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _signedOut, + ); + + expect( + find.byKey(const ValueKey('library-sign-in-state')), + findsOneWidget, + ); + expect(find.text('Local Plan'), findsNothing); + expect(find.text('Local Folder'), findsNothing); + // Nothing to search, sort, or create until the user is signed in. + expect(find.byKey(const ValueKey('library-new-strategy')), findsNothing); + expect(find.byKey(const ValueKey('library-sort-menu')), findsNothing); + expect(find.byKey(const ValueKey('library-search')), findsNothing); + expect(find.byKey(const ValueKey('web-beta-tag')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('library-sign-in-action'))); + await tester.pumpAndSettle(); + expect(find.byType(AuthDialog), findsOneWidget); + expect(app.repository.calls, isEmpty); + expect(await localLibrary(tester), before); + }); + + testWidgets('restoring, then configuring, then ready lands on the cloud', + (tester) async { + final before = await localLibrary(tester); + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _restoring, + settle: false, + ); + + // No login prompt flashes at a returning user. + expect(find.byKey(const ValueKey('cloud-loading')), findsOneWidget); + expect(find.byKey(const ValueKey('library-sign-in-state')), findsNothing); + + app.auth.set(_configuring); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('cloud-loading')), findsOneWidget); + expect(find.byKey(const ValueKey('library-sign-in-state')), findsNothing); + + app.auth.set(_ready); + await tester.pumpAndSettle(); + expect(app.workspace, LibraryWorkspace.cloud); + expect(find.text('Cloud Plan'), findsOneWidget); + expect(find.text('Local Plan'), findsNothing); + expect(find.text('On this device'), findsNothing); + expect(await localLibrary(tester), before); + }); + + testWidgets( + 'ready auth while the workspace is still local creates in the cloud', + (tester) async { + final before = await localLibrary(tester); + // The real workspace notifier starts local; the navigator moves it. + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _ready, + ); + expect(app.workspace, LibraryWorkspace.cloud); + + await tester.tap(find.byKey(const ValueKey('library-new-strategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-strategy-map-ascent'))); + await _settleToasts(tester); + expect(app.repository.calls, ['createStrategyWithInitialPage']); + + await tester.tap(find.byKey(const ValueKey('library-new-menu'))); + await tester.pumpAndSettle(); + // Import and export stay in the desktop app. + expect(find.text('Import .ica'), findsNothing); + expect(find.text('Import Backup'), findsNothing); + expect(find.text('Export Library'), findsNothing); + await tester.tap(find.byKey(const ValueKey('library-new-folder'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pumpAndSettle(); + expect( + app.repository.calls, + ['createStrategyWithInitialPage', 'createFolder'], + ); + + expect(await localLibrary(tester), before); + }); + + testWidgets('losing auth with the map picker open writes nothing local', + (tester) async { + final before = await localLibrary(tester); + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _ready, + ); + + await tester.tap(find.byKey(const ValueKey('library-new-strategy'))); + await tester.pumpAndSettle(); + app.auth.set(_signedOut); + await tester.pump(); + expect(app.workspace, LibraryWorkspace.local); + + await tester.tap(find.byKey(const ValueKey('create-strategy-map-ascent'))); + await tester.pumpAndSettle(); + // The local branch refused; the user is told, nothing is saved. + expect(find.text("Couldn't create strategy right now."), findsOneWidget); + await _settleToasts(tester); + + expect(app.repository.calls, isEmpty); + expect(await localLibrary(tester), before); + }); + + testWidgets('losing auth with the folder dialog open writes nothing local', + (tester) async { + final before = await localLibrary(tester); + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _ready, + ); + + await tester.tap(find.byKey(const ValueKey('library-new-menu'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('library-new-folder'))); + await tester.pumpAndSettle(); + app.auth.set(_signedOut); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('folder-edit-submit'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('folder-edit-failure')), findsOneWidget); + expect(app.repository.calls, isEmpty); + expect(await localLibrary(tester), before); + }); + + testWidgets('signed in without the cloud offers a retry, not a login', + (tester) async { + final app = await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _unreachable, + ); + + expect( + find.byKey(const ValueKey('library-cloud-unreachable')), + findsOneWidget, + ); + expect(find.text("Couldn't connect to cloud sync. Please retry."), + findsOneWidget); + expect(find.byKey(const ValueKey('library-sign-in-action')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('library-cloud-retry'))); + await tester.pumpAndSettle(); + expect(app.auth.reinitializeCalls, 1); + + // Shared cannot open either, and still never asks for a second login. + await tester.tap(find.byKey(const ValueKey('library-tab-shared'))); + await tester.pumpAndSettle(); + expect(find.byType(AuthDialog), findsNothing); + expect( + find.byKey(const ValueKey('library-cloud-unreachable')), + findsOneWidget, + ); + }); + + testWidgets('a server that needs a newer Icarus offers no retry', + (tester) async { + await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _unreachable.copyWith( + errorMessage: clientUpgradeRequiredMessage(), + ), + ); + ProviderScope.containerOf(tester.element(find.byType(FolderNavigator))) + .read(clientUpgradeRequiredProvider.notifier) + .noteError(clientUpgradeRequiredQueueError); + await tester.pumpAndSettle(); + + expect(find.text('Your cloud library needs a newer Icarus'), + findsOneWidget); + // Tests run as desktop, where this harness knows of no update yet. + expect( + find.text(clientUpgradeRequiredMessage( + update: ClientUpdateAvailability.unavailable, + )), + findsOneWidget, + ); + expect(find.text('Check again'), findsOneWidget); + expect(find.byKey(const ValueKey('library-cloud-retry')), findsNothing); + }); + + testWidgets('Beta tag explains what is desktop-only', + (tester) async { + await _pumpLibrary( + tester, + policy: PlatformPolicy.webBeta, + auth: _signedOut, + ); + + await tester.tap(find.byKey(const ValueKey('web-beta-tag'))); + await tester.pumpAndSettle(); + + expect(find.byType(WebBetaDialog), findsOneWidget); + expect(find.text('Icarus web beta'), findsOneWidget); + expect(find.text('Desktop-only for now'), findsOneWidget); + expect( + find.text( + 'Export · Import · Drag and drop', + ), + findsOneWidget, + ); + // Nothing is waiting to come, so the list is not drawn. + expect(find.text('Coming to the web beta'), findsNothing); + expect(find.textContaining('Adding images'), findsNothing); + expect(find.byKey(const ValueKey('web-beta-download')), findsOneWidget); + }); + }); + + group('desktop', () { + testWidgets('signed out, the local library is listed with no Beta tag', + (tester) async { + await _pumpLibrary( + tester, + policy: PlatformPolicy.desktop, + auth: _signedOut, + ); + + expect(find.text('Local Plan'), findsOneWidget); + expect(find.text('Local Folder'), findsOneWidget); + expect(find.byKey(const ValueKey('library-sign-in-state')), findsNothing); + expect(find.byKey(const ValueKey('web-beta-tag')), findsNothing); + expect(find.byKey(const ValueKey('library-new-strategy')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('library-new-menu'))); + await tester.pumpAndSettle(); + expect(find.text('Import .ica'), findsOneWidget); + expect(find.text('Import Backup'), findsOneWidget); + expect(find.text('Export Library'), findsOneWidget); + }); + + testWidgets('signed in, local and cloud strategies sit side by side', + (tester) async { + await _pumpLibrary( + tester, + policy: PlatformPolicy.desktop, + auth: _ready, + ); + + expect(find.text('Cloud Plan'), findsOneWidget); + expect(find.text('Local Plan'), findsOneWidget); + expect(find.byKey(const ValueKey('web-beta-tag')), findsNothing); + }); + }); + + group('PlatformPolicy', () { + test('desktop offers every feature', () { + for (final feature in PlatformFeature.values) { + expect(PlatformPolicy.desktop.supports(feature), isTrue); + expect(PlatformPolicy.desktop.unavailableMessage(feature), isNull); + } + expect(PlatformPolicy.desktop.allowsLocalLibrary, isTrue); + expect(PlatformPolicy.desktop.requiresSignIn, isFalse); + }); + + test('web beta names the feature and why it is missing', () { + const policy = PlatformPolicy.webBeta; + expect( + policy.unavailableMessage(PlatformFeature.exportFiles), + 'Export is desktop-only for now.', + ); + expect(policy.allowsLocalLibrary, isFalse); + expect(policy.requiresSignIn, isTrue); + }); + + test('web beta adds images and lineups', () { + const policy = PlatformPolicy.webBeta; + for (final feature in [ + PlatformFeature.addImages, + PlatformFeature.addLineups, + ]) { + expect(policy.supports(feature), isTrue); + expect(policy.unavailableMessage(feature), isNull); + } + expect(policy.comingToWebBeta, isEmpty); + }); + + test('a feature coming to the web beta says so', () { + const policy = PlatformPolicy( + isWebBeta: true, + allowsLocalLibrary: false, + requiresSignIn: true, + desktopOnly: {}, + comingToWebBeta: {PlatformFeature.addLineups}, + ); + expect(policy.supports(PlatformFeature.addLineups), isFalse); + expect( + policy.unavailableMessage(PlatformFeature.addLineups), + 'Adding lineups is coming to the web beta.', + ); + }); + }); +} + +const _signedOut = AppAuthState( + isLoading: false, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _restoring = AppAuthState( + isLoading: true, + isAuthenticated: false, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.signedOut, + user: null, +); + +const _configuring = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.configuring, + user: null, +); + +const _ready = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: true, + convexAuthStatus: ConvexAuthStatus.ready, + user: null, +); + +/// What auth_provider leaves behind when Convex setup fails: the session +/// stays, the cloud does not answer. +const _unreachable = AppAuthState( + isLoading: false, + isAuthenticated: true, + isConvexUserReady: false, + convexAuthStatus: ConvexAuthStatus.incident, + user: null, + errorMessage: "Couldn't connect to cloud sync. Please retry.", +); + +class _Library { + _Library(this.tester, this.auth, this.repository); + + final WidgetTester tester; + final _TestAuth auth; + final _RecordingRepository repository; + + LibraryWorkspace get workspace => ProviderScope.containerOf( + tester.element(find.byType(FolderNavigator)), + ).read(libraryWorkspaceProvider); +} + +Future<_Library> _pumpLibrary( + WidgetTester tester, { + required PlatformPolicy policy, + required AppAuthState auth, + bool settle = true, +}) async { + // The strip needs a desktop-width window; the test font is wide. + tester.view.physicalSize = const Size(1600, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final testAuth = _TestAuth(auth); + final repository = _RecordingRepository(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + platformPolicyProvider.overrideWithValue(policy), + authProvider.overrideWith(() => testAuth), + convexStrategyRepositoryProvider.overrideWithValue(repository), + appUpdateStatusProvider.overrideWith( + (ref) async => const UpdateCheckResult( + isSupported: false, + isUpdateAvailable: false, + source: 'test', + ), + ), + desktopUpdateControllerProvider.overrideWithValue(null), + cloudFolderTreeProvider.overrideWith( + (_) => Stream.value(const []), + ), + cloudStrategiesProvider.overrideWith( + (_) => Stream.value([ + ( + strategy: _strategy('cloud-plan', 'Cloud Plan'), + revision: 1, + role: 'owner', + attackLabel: 'Attack', + ), + ]), + ), + ], + child: const ToastificationWrapper( + child: ShadApp(home: FolderNavigator()), + ), + ), + ); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } + return _Library(tester, testAuth, repository); +} + +/// Toasts close on a timer; let them run out so no timer outlives the test. +Future _settleToasts(WidgetTester tester) async { + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); +} + +StrategyData _strategy(String id, String name) { + final at = DateTime(2026, 9, 1); + return StrategyData( + id: id, + name: name, + mapData: MapValue.ascent, + versionNumber: 1, + folderID: null, + pages: const [], + createdAt: at, + lastEdited: at, + ); +} + +class _TestAuth extends AuthProvider { + _TestAuth(this._initial); + + final AppAuthState _initial; + int reinitializeCalls = 0; + + @override + AppAuthState build() => _initial; + + void set(AppAuthState next) => state = next; + + @override + Future reinitializeConvexAuth({String source = 'manual'}) async { + reinitializeCalls++; + } +} + +/// Records cloud creates. Strategy creation then fails, so the test stays in +/// the library instead of opening the editor. +class _RecordingRepository extends Fake implements ConvexStrategyRepository { + final calls = []; + + @override + Future createStrategyWithInitialPage({ + required String publicId, + required String name, + required String mapData, + required String initialPagePublicId, + required String initialPageName, + bool? initialPageIsAutoNamed, + required bool initialPageIsAttack, + String? folderPublicId, + String? themeProfileId, + Map? themeOverridePalette, + Map? initialPageSettings, + }) async { + calls.add('createStrategyWithInitialPage'); + throw StateError('Test server does not open strategies.'); + } + + @override + Future createFolder({ + required String publicId, + required String name, + String? parentFolderPublicId, + int? iconId, + int? iconCodePoint, + String? iconFontFamily, + String? iconFontPackage, + String? color, + int? customColorValue, + }) async { + calls.add('createFolder'); + } +} diff --git a/test/windows_desktop_update_controller_test.dart b/test/windows_desktop_update_controller_test.dart index 09a02c4f..2b939f27 100644 --- a/test/windows_desktop_update_controller_test.dart +++ b/test/windows_desktop_update_controller_test.dart @@ -23,7 +23,8 @@ void main() { () async { final installDirectory = await Directory.systemTemp.createTemp('icarus_update_install_'); - final executablePath = '${installDirectory.path}\\icarus.exe'; + final executablePath = + [installDirectory.path, 'icarus.exe'].join(Platform.pathSeparator); await File(executablePath).writeAsBytes(utf8.encode('old exe')); final remoteFiles = >{ @@ -117,7 +118,11 @@ void main() { ...WindowsDesktopUpdateController.splitRelativePath(file.filePath), ].join(Platform.pathSeparator), ); - expect(await stagedFile.exists(), isTrue); + expect( + await stagedFile.exists(), + isTrue, + reason: 'Expected staged update file at ${stagedFile.path}', + ); expect(await stagedFile.readAsBytes(), remoteFiles[file.filePath]); } @@ -129,7 +134,8 @@ void main() { test('downloadUpdate preserves verification errors during cleanup', () async { final installDirectory = await Directory.systemTemp.createTemp('icarus_update_cleanup_'); - final executablePath = '${installDirectory.path}\\icarus.exe'; + final executablePath = + [installDirectory.path, 'icarus.exe'].join(Platform.pathSeparator); await File(executablePath).writeAsBytes(utf8.encode('old exe')); addTearDown(() async { if (await installDirectory.exists()) { diff --git a/test/windows_online_beta_library_probe_test.dart b/test/windows_online_beta_library_probe_test.dart new file mode 100644 index 00000000..2f911dcc --- /dev/null +++ b/test/windows_online_beta_library_probe_test.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:icarus/const/hive_boxes.dart'; +import 'package:icarus/hive/hive_registration.dart'; +import 'package:icarus/strategy/strategy_migrator.dart'; +import 'package:icarus/strategy/strategy_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final supportDirectory = + Platform.environment['ICARUS_WINDOWS_SMOKE_SUPPORT_DIR']; + final probePath = Platform.environment['ICARUS_WINDOWS_SMOKE_PROBE_PATH']; + final expectedStrategyName = + Platform.environment['ICARUS_WINDOWS_SMOKE_EXPECTED_STRATEGY_NAME']; + final isWindowsSmoke = supportDirectory != null && + probePath != null && + expectedStrategyName != null; + + test( + 'reads the public-build library and writes a current semantic fingerprint', + () async { + try { + Hive.init(supportDirectory!); + registerIcarusAdapters(Hive); + expect( + Hive.isAdapterRegistered(200), + isTrue, + reason: 'The production Color adapter must be registered before ' + 'opening a historical strategy box.', + ); + final box = + await Hive.openBox(HiveBoxNames.strategiesBox); + final strategies = box.values.toList(growable: false) + ..sort((left, right) => left.id.compareTo(right.id)); + + expect(strategies, isNotEmpty); + expect( + strategies.any((strategy) => strategy.name == expectedStrategyName), + isTrue, + reason: 'The historical .ica fixture was not imported.', + ); + + final preservationInvariants = { + 'strategies': strategies + .map( + (strategy) => { + 'id': strategy.id, + 'name': strategy.name, + 'map': strategy.mapData.name, + 'folderID': strategy.folderID, + 'pages': strategy.pages + .map( + (page) => { + 'id': page.id, + 'sortIndex': page.sortIndex, + 'name': page.name, + 'isAttack': page.isAttack, + 'drawingIDs': page.drawingData + .map((element) => element.id) + .toList(growable: false), + 'agentIDs': page.agentData + .map((agent) => agent.id) + .toList(growable: false), + 'abilityIDs': page.abilityData + .map((ability) => ability.id) + .toList(growable: false), + 'textIDs': page.textData + .map((text) => text.id) + .toList(growable: false), + 'imageIDs': page.imageData + .map((image) => image.id) + .toList(growable: false), + 'utilityIDs': page.utilityData + .map((utility) => utility.id) + .toList(growable: false), + 'lineups': page.lineUpGroups + .map( + (group) => { + 'id': group.id, + 'agentID': group.agent.id, + 'items': group.items + .map( + (item) => { + 'id': item.id, + 'abilityID': item.ability.id, + 'imageIDs': item.images + .map((image) => image.id) + .toList(growable: false), + }, + ) + .toList(growable: false), + }, + ) + .toList(growable: false), + }, + ) + .toList(growable: false), + }, + ) + .toList(growable: false), + }; + + final canonicalStrategies = >[]; + for (final storedStrategy in strategies) { + expect(storedStrategy.pages, isNotEmpty); + final currentStrategy = + await StrategyMigrator.migrateLegacyData(storedStrategy); + canonicalStrategies.add({ + 'id': currentStrategy.id, + 'name': currentStrategy.name, + 'map': currentStrategy.mapData.name, + 'folderID': currentStrategy.folderID, + 'themeProfileId': currentStrategy.themeProfileId, + 'themeOverridePalette': + currentStrategy.themeOverridePalette?.toJson(), + 'pages': currentStrategy.pages + .map((page) => page.toJson(currentStrategy.id)) + .toList(growable: false), + }); + } + + final outputFile = File(probePath!); + await outputFile.parent.create(recursive: true); + await outputFile.writeAsString( + jsonEncode({ + 'strategyCount': strategies.length, + 'strategyNames': strategies + .map((strategy) => strategy.name) + .toList(growable: false), + 'storedVersions': strategies + .map((strategy) => strategy.versionNumber) + .toList(growable: false), + 'preservationInvariants': jsonEncode(preservationInvariants), + 'canonicalCurrentState': jsonEncode({ + 'strategies': canonicalStrategies, + }), + }), + flush: true, + ); + } finally { + await Hive.close(); + } + }, + skip: isWindowsSmoke + ? false + : 'Only runs inside the disposable Windows upgrade smoke.', + ); +} diff --git a/test/world_based_media_sizing_test.dart b/test/world_based_media_sizing_test.dart index d220c95a..c61f2f24 100644 --- a/test/world_based_media_sizing_test.dart +++ b/test/world_based_media_sizing_test.dart @@ -41,7 +41,8 @@ void main() { ); }); - test('text JSON migration converts legacy pixel width and font sizing once', () { + test('text JSON migration converts legacy pixel width and font sizing once', + () { final migrated = TextProvider.fromJson(jsonEncode([ { 'id': 'text-1', @@ -82,7 +83,6 @@ void main() { 'scale': 200, 'fileExtension': null, 'tagColorValue': null, - 'link': '', } ]), strategyID: 'strategy-id', @@ -103,7 +103,6 @@ void main() { 'sizeVersion': worldSizedMediaVersion, 'fileExtension': null, 'tagColorValue': null, - 'link': '', } ]), strategyID: 'strategy-id', diff --git a/third_party/convex_flutter/ARCHITECTURE.md b/third_party/convex_flutter/ARCHITECTURE.md new file mode 100644 index 00000000..b66d6013 --- /dev/null +++ b/third_party/convex_flutter/ARCHITECTURE.md @@ -0,0 +1,784 @@ +# Architecture & Platform Support - convex_flutter + +## v3.0.0 Major Update: Web Platform Support 🌐 + +**convex_flutter** now supports **ALL Flutter platforms** including web! The package intelligently uses different implementations based on the target platform: + +- **Web**: Pure Dart implementation (no Rust required) +- **Native** (Android, iOS, macOS, Windows, Linux): FFI + Rust SDK + +## Table of Contents +- [Platform Architecture Overview](#platform-architecture-overview) +- [Web Platform Implementation (NEW in v3.0.0)](#web-platform-implementation-new-in-v300) +- [Native Platform Implementation](#native-platform-implementation) +- [Why Rust is Required (Native Only)](#why-rust-is-required-native-only) +- [Who Needs Rust Installed](#who-needs-rust-installed) +- [How the Package Works](#how-the-package-works) +- [Can Rust Dependency Be Removed?](#can-rust-dependency-be-removed) +- [Alternatives & Tradeoffs](#alternatives--tradeoffs) +- [Impact on Developers](#impact-on-developers) +- [Future Possibilities](#future-possibilities) + +--- + +## Platform Architecture Overview + +### Multi-Platform Implementation Strategy + +The package uses **conditional imports** to select the appropriate implementation at compile time: + +```dart +// lib/src/convex_client.dart +import 'impl/convex_client_native.dart' // FFI + Rust + if (dart.library.js_interop) 'impl/convex_client_web.dart'; // Pure Dart +``` + +### Architecture Comparison + +| Platform | Implementation | Rust Required | WebSocket Source | +|----------|---------------|---------------|------------------| +| **Web** | Pure Dart | ❌ No | Browser WebSocket API | +| **Android** | FFI + Rust | ✅ Yes (build-time) | Convex Rust SDK | +| **iOS** | FFI + Rust | ✅ Yes (build-time) | Convex Rust SDK | +| **macOS** | FFI + Rust | ✅ Yes (build-time) | Convex Rust SDK | +| **Windows** | FFI + Rust | ✅ Yes (build-time) | Convex Rust SDK | +| **Linux** | FFI + Rust | ✅ Yes (build-time) | Convex Rust SDK | + +--- + +## Web Platform Implementation (NEW in v3.0.0) + +### Why Web Needed Different Approach + +**Problem**: FFI (Foreign Function Interface) doesn't work on web platform +- Web runs in browser JavaScript sandbox +- Cannot execute native compiled code +- `dart:ffi` is not available on web + +**Solution**: Implement Convex WebSocket protocol in pure Dart + +### Web Architecture + +``` +┌─────────────────────────────────────┐ +│ Dart Layer (Flutter Web App) │ ← Your app code +│ - ConvexClient API (same as native)│ +│ - Streams, Futures │ +│ - Flutter-friendly interfaces │ +└──────────────┬──────────────────────┘ + │ Direct Dart calls (no FFI) +┌──────────────▼──────────────────────┐ +│ WebConvexClient (Pure Dart) │ ← Pure Dart implementation +│ - WebSocket management │ +│ - Convex wire protocol │ +│ - State management │ +│ - Subscription handling │ +└──────────────┬──────────────────────┘ + │ package:web WebSocket API +┌──────────────▼──────────────────────┐ +│ Browser WebSocket │ ← Browser native API +│ - Real-time WebSocket client │ +│ - Managed by browser │ +│ - No compilation required │ +└─────────────────────────────────────┘ +``` + +### Web Implementation Details + +**File**: `lib/src/impl/convex_client_web.dart` (~800 lines of pure Dart) + +**Key Features Implemented**: +- ✅ RFC 4122 compliant UUID v4 generation for session IDs +- ✅ Convex WebSocket wire protocol implementation: + - Connect message with session management + - ModifyQuerySet with version tracking + - Mutation/Action/Query execution + - Transition messages for real-time updates + - Ping/Pong heartbeat +- ✅ Real-time subscriptions with automatic cleanup +- ✅ Connection state monitoring +- ✅ Automatic reconnection with exponential backoff +- ✅ Authentication token management +- ✅ Error handling and timeout management + +**Dependencies (Web Only)**: +```yaml +dependencies: + web: ^1.0.0 # Browser WebSocket API access + http: ^1.2.0 # HTTP client for REST fallback (future) +``` + +**Protocol Messages**: +```dart +// Connect +{ + "type": "Connect", + "sessionId": "550e8400-e29b-41d4-a716-446655440000", // RFC 4122 UUID + "maxObservedTimestamp": null, + "connectionCount": 1, + "clientTs": 1704931200000, + "lastCloseReason": null +} + +// ModifyQuerySet (subscribe) +{ + "type": "ModifyQuerySet", + "baseVersion": 0, + "newVersion": 1, + "modifications": [{ + "type": "Add", + "queryId": 1, + "udfPath": "messages:list", + "args": [{}] + }] +} + +// Mutation +{ + "type": "Mutation", + "requestId": 1, // u32 integer + "udfPath": "messages:send", + "args": [{"body": "Hello"}] +} +``` + +### Web vs Native API Parity + +**100% API Compatibility**: Same public API works on both platforms + +```dart +// This exact code works identically on web AND native! +final client = ConvexClient.instance; + +// Queries +final result = await client.query('users:list', {}); + +// Mutations +await client.mutation(name: 'messages:send', args: {'body': 'Hi'}); + +// Subscriptions +final sub = await client.subscribe( + name: 'messages:list', + args: {}, + onUpdate: (data) => print(data), + onError: (msg, data) => print(msg), +); + +// Connection state +client.connectionState.listen((state) => print(state)); + +// Authentication +await client.setAuth(token: 'jwt-token'); +``` + +--- + +## Native Platform Implementation + +### TL;DR +**Native platforms use FFI (Foreign Function Interface) to wrap the official Convex Rust SDK.** This means the core Convex client logic is written in Rust and compiled to native code, with Dart code calling into it via FFI. + +### Technical Explanation + +The package architecture involves three layers: + +``` +┌─────────────────────────────────────┐ +│ Dart Layer (Flutter App) │ ← Your app code +│ - ConvexClient API │ +│ - Streams, Futures │ +│ - Flutter-friendly interfaces │ +└──────────────┬──────────────────────┘ + │ FFI Bridge (flutter_rust_bridge) +┌──────────────▼──────────────────────┐ +│ Rust Layer (Native Code) │ ← Compiled Rust +│ - MobileConvexClient │ +│ - WebSocket management │ +│ - State management │ +└──────────────┬──────────────────────┘ + │ Rust library dependency +┌──────────────▼──────────────────────┐ +│ Convex Rust SDK (convex crate) │ ← Official Convex SDK +│ - Real-time WebSocket client │ +│ - Query/Mutation/Action execution │ +│ - Connection management │ +└─────────────────────────────────────┘ +``` + +### Key Dependencies + +**In `pubspec.yaml`:** +```yaml +dependencies: + flutter_rust_bridge: ^2.11.1 # Dart ↔ Rust FFI bridge + ffi: ^2.1.3 # Dart FFI support +``` + +**In `rust/Cargo.toml`:** +```toml +[dependencies] +convex = { version = "0.9" } # Official Convex Rust SDK +flutter_rust_bridge = "=2.11.1" # Bridge codegen +tokio = { version = "1", features = ["full"] } # Async runtime +``` + +**Plugin Configuration in `pubspec.yaml`:** +```yaml +flutter: + plugin: + platforms: + android: + ffiPlugin: true # ← This marks it as FFI plugin + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true +``` + +### Why Use Rust Instead of Pure Dart? + +1. **Official SDK**: Convex provides an official Rust SDK with full WebSocket support +2. **Performance**: Native code (compiled Rust) is faster than interpreted Dart for intensive operations +3. **Code Reuse**: Leverage the battle-tested Convex Rust client instead of reimplementing from scratch +4. **Real-time Features**: WebSocket management, connection pooling, and async I/O are built-in +5. **Type Safety**: Rust's strong type system catches errors at compile time +6. **Cross-platform**: Rust compiles to all Flutter platforms (Android, iOS, Windows, macOS, Linux) + +--- + +## Who Needs Rust Installed + +### 1. Package Developers (Maintainers) ✅ NEED RUST + +**Who**: Anyone modifying the convex_flutter package itself + +**Why**: To build and test Rust code changes + +**Requirements**: +```bash +# Install Rust toolchain +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Verify installation +rustc --version +cargo --version + +# Platform-specific tools +# Android: NDK (via Android SDK Manager) +# iOS/macOS: Xcode Command Line Tools +# Windows: Visual Studio Build Tools (C++) +# Linux: build-essential, clang, pkg-config +``` + +### 2. App Developers (Package Users) ⚠️ CURRENTLY NEED RUST + +**Who**: Anyone building a Flutter app that depends on `convex_flutter` + +**Why**: Flutter's build system compiles the Rust code when building your app + +**The Problem**: This is a significant barrier to adoption. Most Flutter developers don't have Rust installed and shouldn't need to. + +**What Happens When Building**: +```bash +# When you run: +flutter build apk + +# Flutter build system: +1. Detects FFI plugin (ffiPlugin: true) +2. Looks for Rust source in rust/ directory +3. Invokes `cargo build --release` for target platform +4. Compiles Rust code to native library (.so, .dylib, .dll) +5. Bundles native library into app package +6. ❌ FAILS if Rust toolchain not installed +``` + +**Error Without Rust**: +``` +Error: Unable to find cargo in PATH. Rust toolchain is required. +Please install Rust from https://rustup.rs +``` + +### 3. End Users (App Users) ✅ DON'T NEED RUST + +**Who**: People downloading your app from App Store/Play Store + +**Why**: The compiled native libraries are bundled in the app package + +**What They Get**: Pre-compiled native code (no Rust needed) + +--- + +## How the Package Works + +### Build Process + +```mermaid +graph TD + A[flutter build] --> B{Detect FFI Plugin} + B --> C[Invoke cargo build] + C --> D[Compile Rust → Native Library] + D --> E[Bundle .so/.dylib/.dll into app] + E --> F[App can call Rust via FFI] +``` + +### Runtime Flow + +**Example: Executing a Query** + +```dart +// 1. Dart: User calls query +final result = await ConvexClient.instance.query('users:list', {}); +``` + +↓ + +```dart +// 2. Dart: ConvexClient calls Rust via FFI bridge +final rustResult = await _mobileClient.query( + name: 'users:list', + args: '{}', + timeout: Duration(seconds: 30), +); +``` + +↓ + +```rust +// 3. Rust: MobileConvexClient receives call +pub async fn query(&self, name: String, args: String, timeout: Duration) -> Result { + let client = self.connected_client().await?; // Get Convex client + + // Parse args, execute query via Convex SDK + let result = client.query(name, args).await?; + + // Return JSON string to Dart + Ok(serde_json::to_string(&result)?) +} +``` + +↓ + +```rust +// 4. Convex Rust SDK: Execute query +// - Establish WebSocket connection +// - Send query request +// - Receive response +// - Return result +``` + +↓ + +```dart +// 5. Dart: Return result to app +return jsonDecode(rustResult); +``` + +### File Structure + +``` +convex_flutter/ +├── lib/ # Dart code (Flutter layer) +│ ├── convex_flutter.dart # Public API +│ └── src/ +│ ├── convex_client.dart # Main client (Dart) +│ ├── rust/ # Generated FFI bindings +│ │ └── lib.dart # Auto-generated by flutter_rust_bridge +│ └── *.dart # Other Dart types/utilities +│ +├── rust/ # Rust code (Native layer) +│ ├── Cargo.toml # Rust dependencies +│ ├── src/ +│ │ ├── lib.rs # Main Rust implementation (wraps Convex SDK) +│ │ └── frb_generated.rs # Auto-generated FFI bindings +│ └── target/ # Compiled Rust artifacts (1.9GB+) +│ +├── pubspec.yaml # Flutter package config (ffiPlugin: true) +└── README.md # Package documentation +``` + +**Code Statistics**: +- **Dart files**: 33 (UI, API, types) +- **Rust files**: 2 (core client logic) +- **Lines of Rust**: ~800 lines wrapping Convex SDK + +--- + +## Can Rust Dependency Be Removed? + +### Short Answer: **Technically YES, but at SIGNIFICANT cost** + +### Long Answer: Multiple Approaches, Each with Major Tradeoffs + +--- + +## Alternatives & Tradeoffs + +### Option 1: Pure Dart Implementation ❌ NOT RECOMMENDED + +**Approach**: Rewrite entire Convex client in Dart + +**Pros**: +- ✅ No Rust dependency +- ✅ Easier for Flutter developers to contribute +- ✅ No FFI bridge overhead +- ✅ Single language ecosystem + +**Cons**: +- ❌ **MASSIVE development effort** (thousands of lines of code) +- ❌ Reimplementing WebSocket protocol, connection management, state handling +- ❌ Maintaining parity with official Convex SDK features +- ❌ Testing and bug fixes (Rust SDK is battle-tested) +- ❌ Ongoing maintenance burden (keeping up with Convex API changes) +- ❌ Potential performance issues (Dart vs native code) + +**Estimated Effort**: 3-6 months of full-time development + ongoing maintenance + +**Verdict**: Only viable if Convex provides an official Dart SDK + +--- + +### Option 2: Pre-compiled Native Binaries ⚠️ POSSIBLE BUT COMPLEX + +**Approach**: Build Rust code in advance for all platforms, distribute binaries with package + +**How It Works**: +``` +1. Package maintainer builds Rust code for all targets: + - Android: arm64-v8a, armeabi-v7a, x86_64, x86 + - iOS: arm64 (device), x86_64 (simulator) + - macOS: arm64 (Apple Silicon), x86_64 (Intel) + - Windows: x86_64 + - Linux: x86_64, arm64 + +2. Include all binaries in package (in android/libs/, ios/, etc.) + +3. Flutter build system uses pre-built binaries instead of compiling +``` + +**Pros**: +- ✅ App developers don't need Rust installed +- ✅ Faster builds (no Rust compilation) +- ✅ Same functionality as current implementation + +**Cons**: +- ❌ **Large package size** (~50-100MB for all platforms/architectures) +- ❌ **CI/CD complexity** (must build for 10+ target platforms) +- ❌ **Security concerns** (distributing pre-built binaries) +- ❌ Package maintainer needs all platform build environments +- ❌ pub.dev size limits (10MB for packages, need special approval for larger) +- ❌ Still need Rust for package development + +**Package Size Impact**: +``` +Current package size: 328 KB (source only) +With pre-compiled binaries: ~80-120 MB (all platforms/architectures) + +Breakdown: +- Android (4 architectures): ~20-30 MB +- iOS (2 architectures): ~15-20 MB +- macOS (2 architectures): ~15-20 MB +- Windows: ~10-15 MB +- Linux: ~10-15 MB +``` + +**Verdict**: Solves developer experience but creates distribution challenges + +--- + +### Option 3: REST API Only (No WebSockets) ❌ LOSES KEY FEATURES + +**Approach**: Use Convex HTTP API directly (no WebSockets) + +**Pros**: +- ✅ Pure Dart implementation (easy to write) +- ✅ No Rust dependency +- ✅ Simple HTTP client (`package:http`) + +**Cons**: +- ❌ **NO real-time subscriptions** (major feature loss) +- ❌ **NO automatic reconnection** on network changes +- ❌ **NO connection state management** +- ❌ Must poll for updates (inefficient, battery drain) +- ❌ Higher latency for real-time features +- ❌ Not using official Convex SDK + +**What You Lose**: +```dart +// ❌ Real-time subscriptions (lost) +client.subscribe( + name: 'messages:list', + args: {}, + onUpdate: (messages) => print('New messages: $messages'), +); + +// ❌ Connection state monitoring (lost) +client.connectionState.listen((state) { + print('Connection: $state'); +}); + +// ❌ Automatic reconnection (lost) +// ❌ WebSocket efficiency (lost) +``` + +**Verdict**: Only viable for simple apps without real-time requirements + +--- + +### Option 4: Hybrid Approach ⚠️ BEST COMPROMISE + +**Approach**: Offer TWO packages + +1. **`convex_flutter`** (current): Full-featured FFI package with Rust +2. **`convex_flutter_lite`** (new): Pure Dart HTTP-only version + +**Pros**: +- ✅ Developers choose based on needs +- ✅ Simple apps can avoid Rust dependency +- ✅ Advanced apps get full features +- ✅ Clear upgrade path (lite → full) + +**Cons**: +- ❌ Maintain two packages +- ❌ Feature parity issues +- ❌ Documentation duplication +- ❌ Potential confusion for users + +**Verdict**: Good middle ground if demand justifies the effort + +--- + +### Option 5: Official Convex Dart SDK 🎯 IDEAL SOLUTION + +**Approach**: Ask Convex to provide an official Dart/Flutter SDK + +**Pros**: +- ✅ No Rust dependency (if written in Dart) +- ✅ Official support from Convex +- ✅ Feature parity guaranteed +- ✅ Professional maintenance + +**Cons**: +- ❌ Outside our control +- ❌ May not happen (Convex prioritizes other platforms) +- ❌ Timeline uncertain + +**Action**: Submit feature request to Convex team + +**Verdict**: Best long-term solution, but not immediately available + +--- + +## Impact on Developers + +### Current Developer Experience (With Rust) + +**First-time setup**: +```bash +# 1. Install Rust (5-10 minutes) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env + +# 2. Install platform tools +# Android: Install NDK via Android SDK Manager +# iOS/macOS: xcode-select --install +# Windows: Install Visual Studio Build Tools +# Linux: sudo apt-get install build-essential clang pkg-config + +# 3. Add package to Flutter project +flutter pub add convex_flutter + +# 4. First build (slow - compiles Rust) +flutter run # Takes 2-5 minutes on first build + +# 5. Subsequent builds (faster - uses cache) +flutter run # Takes 30-60 seconds +``` + +**Common Issues**: +- ❌ "cargo: command not found" → Rust not installed +- ❌ "NDK not found" → Android NDK missing +- ❌ Long build times (Rust compilation adds 1-3 minutes) +- ❌ Large build artifacts (rust/target/ = 1.9GB) + +**Comparison to Pure Dart Packages**: +```bash +# Pure Dart package (e.g., http, provider, riverpod) +flutter pub add http # Done in 5 seconds +flutter run # Builds in 30 seconds + +# convex_flutter (FFI plugin) +flutter pub add convex_flutter # Requires Rust setup (10 minutes) +flutter run # Builds in 3-5 minutes (first time) +``` + +--- + +## Future Possibilities + +### 1. Streamlined Rust Installation 🔧 + +**Idea**: Provide automated setup script + +```bash +# Example setup script +curl -sSf https://raw.githubusercontent.com/jkuldev/convex_flutter/main/setup.sh | sh + +# Script would: +# 1. Detect OS (macOS, Linux, Windows) +# 2. Install Rust if missing +# 3. Install platform tools (NDK, Xcode, etc.) +# 4. Configure environment +# 5. Run test build +``` + +**Impact**: Reduces setup friction from 30 minutes to 5 minutes + +--- + +### 2. Pre-compiled Binaries via GitHub Releases 📦 + +**Idea**: Host pre-compiled binaries separately, download on demand + +```yaml +# pubspec.yaml +dependencies: + convex_flutter: ^2.2.0 + +# On first build, Flutter plugin downloads pre-built binaries +# from GitHub releases instead of compiling Rust +``` + +**How**: +1. CI/CD builds binaries for all platforms +2. Binaries uploaded to GitHub Releases +3. Flutter plugin downloads correct binary for target platform +4. Falls back to Rust compilation if download fails + +**Impact**: +- ✅ App developers don't need Rust +- ✅ Faster builds +- ❌ Still large downloads (~10-20MB per platform) + +--- + +### 3. Official Convex Dart SDK 🎯 + +**Ideal Long-term Solution**: Convex provides official Dart/Flutter SDK + +**Request to Convex**: +``` +Subject: Feature Request - Official Dart/Flutter SDK + +Dear Convex Team, + +We maintain convex_flutter, a community package wrapping your Rust SDK. +Current architecture requires all Flutter developers to install Rust, +which is a significant adoption barrier. + +Would Convex consider providing an official Dart/Flutter SDK? + +Benefits: +- Wider Flutter ecosystem adoption +- Better developer experience +- Official support and maintenance +- Feature parity with other platforms (JS, Python, Rust) + +Thank you for consideration. +``` + +--- + +### 4. WebAssembly (WASM) Compilation 🌐 + +**Future Tech**: Compile Rust to WASM, run in Dart VM + +**Status**: Experimental (Flutter WASM support is evolving) + +**Potential**: +- ✅ No Rust toolchain needed +- ✅ Smaller package size +- ✅ Same Rust code +- ❌ Performance overhead (WASM vs native) +- ❌ Flutter WASM support still maturing + +--- + +## Summary & Recommendations + +### Current State +- ✅ **Fully functional** with real-time WebSocket support +- ✅ **Production-ready** (v2.2.0) +- ❌ **Requires Rust** for all developers (barrier to adoption) + +### Short-term Recommendations + +**For Package Maintainers**: +1. **Document Rust requirement clearly** in README (add prominent warning) +2. **Provide setup guide** with troubleshooting +3. **Add FAQ section** explaining why Rust is needed +4. **Consider pre-compiled binaries** for popular platforms (Android/iOS first) + +**For App Developers**: +1. **Accept Rust requirement** if you need real-time features +2. **Use alternative packages** if Rust is a dealbreaker (e.g., HTTP-only Convex client) +3. **Submit feedback** about Rust requirement (helps prioritize solutions) + +### Long-term Recommendations + +1. **Investigate pre-compiled binaries** (GitHub Actions CI/CD) +2. **Create `convex_flutter_lite`** (pure Dart HTTP-only version) +3. **Request official Dart SDK** from Convex team +4. **Monitor Flutter WASM** progress + +### Decision Matrix + +| Feature | Current (Rust FFI) | Pre-compiled | Pure Dart | HTTP-only | +|---------|-------------------|--------------|-----------|-----------| +| Real-time subscriptions | ✅ | ✅ | ✅ | ❌ | +| Connection state | ✅ | ✅ | ✅ | ❌ | +| No Rust needed | ❌ | ✅ | ✅ | ✅ | +| Small package size | ✅ | ❌ | ✅ | ✅ | +| Easy maintenance | ✅ | ⚠️ | ❌ | ✅ | +| Performance | ✅ | ✅ | ⚠️ | ⚠️ | +| Official SDK parity | ✅ | ✅ | ❌ | ❌ | + +--- + +## FAQ + +### Q: Why not just use HTTP requests instead of WebSockets? +**A**: WebSockets provide real-time bidirectional communication. With HTTP, you'd need to poll for updates, which is inefficient, drains battery, and has higher latency. Convex's real-time subscriptions require WebSockets. + +### Q: Can I use this package without installing Rust? +**A**: ✅ YES for web platform! No Rust required when building for web. For native platforms (Android, iOS, macOS, Windows, Linux), Rust is still required at build-time. If your app only targets web, you can skip Rust installation entirely. + +### Q: Will this work on web platform? +**A**: ✅ YES! As of v3.0.0, web platform is fully supported with a pure Dart implementation. No Rust required for web builds. See [Web Platform Implementation](#web-platform-implementation-new-in-v300) section above. + +### Q: How much does Rust compilation add to build time? +**A**: First build: 2-5 minutes. Subsequent builds: 30-60 seconds (cached). Release builds take longer (5-10 minutes). + +### Q: Can I distribute my app without users needing Rust? +**A**: Yes! End users don't need Rust. The compiled native libraries are bundled in your app package. Only developers building the app need Rust. + +### Q: Is there a roadmap for removing Rust dependency? +**A**: We're investigating pre-compiled binaries for v3.0. Long-term, we hope Convex provides an official Dart SDK. See [Future Possibilities](#future-possibilities) section. + +--- + +## Contributing + +If you have ideas for reducing Rust dependency burden: +1. Open an issue: https://github.com/jkuldev/convex_flutter/issues +2. Discuss in PR: https://github.com/jkuldev/convex_flutter/pulls +3. Contact maintainers: https://jkuldev.com + +--- + +**Document Version**: 2.0 +**Last Updated**: 2026-01-10 +**Package Version**: 3.0.0 diff --git a/third_party/convex_flutter/CHANGELOG.md b/third_party/convex_flutter/CHANGELOG.md new file mode 100644 index 00000000..75dc3b2b --- /dev/null +++ b/third_party/convex_flutter/CHANGELOG.md @@ -0,0 +1,234 @@ +## 3.0.1 + +### Bug Fixes + +- **Fixed argument types from `Map` to `Map`** across all operations (query, mutation, action, subscribe) + - Nested objects (e.g., `paginationOpts`), arrays, numbers, and booleans are now properly supported as argument values + - Fix applied consistently across public API, interface, native, and web implementations + - Removed `toString()` conversion in mutation and action that silently destroyed nested argument structures + - Closes #15 + +## 3.0.0 + +### Major New Features + +- **🌐 Web Platform Support**: Full web platform support with pure Dart implementation + - Uses native browser WebSocket API (no FFI required) + - 100% API compatibility with native platforms + - Automatic platform selection via conditional imports + - No Rust toolchain required for web builds + - All features work identically on web: queries, mutations, actions, subscriptions, auth + +### Web Implementation Details + +- Implemented Convex WebSocket wire protocol in pure Dart: + - RFC 4122 compliant UUID v4 generation for session IDs + - Proper protocol message formatting (Connect, ModifyQuerySet, Mutation, Action, Transition, Ping/Pong) + - Query set version tracking with baseVersion/newVersion + - Integer requestId (u32) for protocol compliance + - Real-time subscription management with automatic cleanup + - Connection state monitoring and automatic reconnection + - Ping/Pong heartbeat for connection keepalive + +### Critical Bug Fixes + +- **Fixed macOS native platform connection issues**: + - Root cause: Missing network entitlements in App Sandbox configuration + - Added `com.apple.security.network.client` to both DebugProfile.entitlements and Release.entitlements + - macOS apps can now establish WebSocket connections to Convex backend + +- **Fixed Android missing INTERNET permission**: + - Added `` to AndroidManifest.xml + - Android apps now have proper network access + +- **Fixed Rust rustls CryptoProvider error**: + - Removed `default-features = false` from convex dependency in Cargo.toml + - rustls 0.23+ now has proper CryptoProvider configuration + +### Improvements + +- **Platform Configuration Documentation**: + - New PLATFORM_CONFIGURATION.md guide with setup instructions for all platforms + - Updated README.md with platform-specific requirements + - Clear troubleshooting guides for common connection issues + +- **Example App**: + - All platforms (web, iOS, Android, macOS) now properly configured + - Works on web without Rust toolchain + - Demonstrates cross-platform compatibility + +- **Rust SDK Update**: + - Upgraded convex SDK from 0.9.0 to 0.10.2 + - Better protocol compatibility with Convex backend + +### Platform Support Matrix + +| Platform | Status | Implementation | Network Config Required | +|----------|--------|----------------|-------------------------| +| Web | ✅ New | Pure Dart | None | +| iOS | ✅ Working | FFI + Rust | None | +| macOS | ✅ Fixed | FFI + Rust | Network entitlements | +| Android | ✅ Fixed | FFI + Rust | INTERNET permission | +| Windows | ✅ Working | FFI + Rust | None | +| Linux | ✅ Working | FFI + Rust | None | + +### API Changes + +None - 100% backward compatible. The same API works across all platforms. + +### Breaking Changes + +None - this is a feature release with bug fixes, no breaking changes to existing API. + +### New Files + +- `lib/src/impl/convex_client_web.dart` - Pure Dart WebSocket implementation for web +- `lib/src/impl/convex_client_native.dart` - FFI implementation for native platforms (refactored) +- `PLATFORM_CONFIGURATION.md` - Comprehensive platform setup guide +- `WEB_SUCCESS.md` - Web implementation verification documentation +- `NATIVE_PLATFORM_FIX.md` - Native platform fixes documentation + +### Modified Files + +- `example/macos/Runner/DebugProfile.entitlements` - Added network permissions +- `example/macos/Runner/Release.entitlements` - Added network permissions +- `example/android/app/src/main/AndroidManifest.xml` - Added INTERNET permission +- `rust/Cargo.toml` - Updated convex SDK and removed default-features = false +- `lib/src/convex_client.dart` - Refactored to use platform-specific implementations +- `README.md` - Added web platform documentation and platform configuration guide + +### Migration Guide + +No migration needed - existing code works without changes on all platforms including web. + +To build for web: +```bash +flutter build web +``` + +No Rust toolchain required for web builds! + +### Known Issues + +None - all platforms tested and working. + +--- + +## 1.0.2 + +- Added support for Dart 3.7.0 +- Added support for Flutter 3.3.0 +- Added support for Flutter 3.10.0 +- Added support for Flutter 3.11.0 +- Added support for Flutter 3.12.0 +- Added support for Flutter 3.13.0 +- Added support for Flutter 3.14.0 + +## 1.0.3 + +- Updated flutter_rust_bridge package to 2.9.0 + +## 1.0.4 + +- Updated flutter_rust_bridge package to 2.10.0 + +## 1.2.0 + + - Package version updated + +## 2.0.0 + +- Replaced ArcSubscriptionHandle with SubscriptionHandle + +## 2.1.0 + +### New Features + +- **Singleton Pattern**: New `ConvexClient.initialize(ConvexConfig)` method with `ConvexClient.instance` access +- **Operation Timeouts**: Configurable timeout for all queries, mutations, and actions (default: 30 seconds) +- **Connection Management**: Manual connection checking with `checkConnection()` and `reconnect()` methods +- **Lifecycle Monitoring**: Stream of app lifecycle events (resumed, paused, inactive, detached) +- **Configuration Class**: New `ConvexConfig` class for cleaner initialization + +### Bug Fixes + +- Fixed critical Rust subscription panic when WebSocket connection closes unexpectedly +- Subscription streams now exit gracefully instead of crashing the app + +### Improvements + +- Better error handling for connection issues with `ConnectionStatus` enum +- App lifecycle integration with `AppLifecycleObserver` +- Comprehensive documentation updates with new usage examples +- Example app updated to demonstrate new features + +### API Changes + +- **Deprecated**: `ConvexClient.init()` is now deprecated, use `ConvexClient.initialize(ConvexConfig)` instead +- **New**: `ConvexClient.instance` - Access singleton anywhere +- **New**: `ConvexClient.initialize(ConvexConfig)` - Initialize with configuration +- **New**: `checkConnection()` - Manual connection status check +- **New**: `reconnect()` - Manual reconnection attempt +- **New**: `lifecycleEvents` stream - Monitor app lifecycle +- **Enhanced**: All queries, mutations, and actions now respect `operationTimeout` + +### Breaking Changes + +None - backward compatibility maintained through deprecated methods + +## 2.2.0 + +### New Features + +- **Real-Time WebSocket Connection State**: Monitor WebSocket connection status via reactive streams + - `connectionState` stream - Real-time connection state updates (Connected/Connecting) + - `currentConnectionState` getter - Synchronous access to current state + - `isConnected` getter - Quick boolean check for connection status + - Automatic state transitions when WebSocket connects/disconnects + - No polling required - pure event-driven updates + +### Bug Fixes + +- **Fixed critical race condition in WebSocket connection initialization** + - Issue: State change callback was registered after WebSocket connection began, causing state transitions to be lost + - Root cause: Async task spawning in `connected_client()` created unpredictable timing delays + - Solution: Removed task spawning and build ConvexClient directly in async context + - Result: Callback is now guaranteed to be registered before `builder.build()` is called + +- **Fixed WebSocket connection state stuck on "connecting"** + - Issue: Example app showed "connecting" forever without transitioning to "connected" + - Root cause: No operations were triggered on app startup, so `connected_client()` was never called + - Solution: Added auto-connection trigger in example app's HomeScreen initialization + - Result: Connection establishes automatically on startup with proper state transitions + +### Improvements + +- Enhanced example app with comprehensive WebSocket connection state demonstrations: + - Connection status indicator in app bar with real-time visual feedback + - Dedicated Connection screen showing current state and history + - Automatic connection on app startup + - All 5 screens demonstrating different SDK capabilities + - Added HEALTH_CHECK.md guide for setting up health check queries + +- Documentation improvements: + - Comprehensive WebSocket connection state usage examples + - Recommended health check query pattern using `health:ping` + - TypeScript example for creating health check query in Convex backend + - Updated all examples to use dedicated health check instead of `messages:list` + - Deprecated `checkConnection()` in favor of real-time `connectionState` stream + +- Code quality: + - Comprehensive debug logging for troubleshooting connection issues + - Better error handling in auto-connection flow + - Clearer comments explaining lazy initialization + +### API Changes + +- **New**: `connectionState` stream - Real-time WebSocket connection state updates (`Stream`) +- **New**: `currentConnectionState` getter - Synchronous access to current connection state +- **New**: `isConnected` getter - Boolean check for WebSocket connection status +- **Deprecated**: `checkConnection()` - Use `connectionState` stream for real-time monitoring instead + +### Breaking Changes + +None - all changes are additive and maintain backward compatibility \ No newline at end of file diff --git a/third_party/convex_flutter/CONTRIBUTING.md b/third_party/convex_flutter/CONTRIBUTING.md new file mode 100644 index 00000000..c1d9e4fd --- /dev/null +++ b/third_party/convex_flutter/CONTRIBUTING.md @@ -0,0 +1,504 @@ +# Contributing to convex_flutter + +Thank you for your interest in contributing to `convex_flutter`! This document provides guidelines and instructions for contributing to the project. + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [How Can I Contribute?](#how-can-i-contribute) +- [Development Setup](#development-setup) +- [Project Structure](#project-structure) +- [Making Changes](#making-changes) +- [Testing](#testing) +- [Submitting Changes](#submitting-changes) +- [Style Guidelines](#style-guidelines) +- [Platform-Specific Contributions](#platform-specific-contributions) + +--- + +## Code of Conduct + +This project adheres to a code of conduct that all contributors are expected to follow: + +- Be respectful and inclusive +- Welcome newcomers and help them get started +- Focus on constructive criticism +- Respect differing viewpoints and experiences +- Accept responsibility and apologize for mistakes + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check existing issues to avoid duplicates. When creating a bug report, include: + +- **Clear title** describing the issue +- **Detailed description** of the problem +- **Steps to reproduce** the behavior +- **Expected vs actual behavior** +- **Environment details**: + - Flutter version (`flutter --version`) + - Dart version + - Platform (Web, Android, iOS, macOS, Windows, Linux) + - Package version + - Rust version (for native platforms) +- **Stack traces or error messages** +- **Minimal reproducible example** if possible + +**Template**: +```markdown +**Description**: Brief description of the issue + +**Steps to Reproduce**: +1. Initialize ConvexClient with... +2. Call query/mutation/subscribe... +3. Observe error... + +**Expected**: What should happen +**Actual**: What actually happens + +**Environment**: +- Flutter: 3.19.0 +- Dart: 3.3.0 +- Platform: Web / Android / iOS / etc. +- convex_flutter: 3.0.0 +- Rust: 1.75.0 (if applicable) + +**Error Output**: +``` +[Paste error here] +``` + +**Additional Context**: Any other relevant information +``` + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include: + +- **Clear title** describing the enhancement +- **Detailed description** of the proposed feature +- **Use case** explaining why this would be useful +- **Proposed implementation** (if you have ideas) +- **Alternatives considered** + +### Pull Requests + +We actively welcome pull requests! To contribute code: + +1. **Fork** the repository +2. **Create a branch** from `main` (`git checkout -b feature/my-feature`) +3. **Make your changes** following our style guidelines +4. **Test your changes** on relevant platforms +5. **Commit your changes** with clear commit messages +6. **Push to your fork** (`git push origin feature/my-feature`) +7. **Open a Pull Request** with a clear description + +--- + +## Development Setup + +### Prerequisites + +**For All Contributors**: +- Flutter SDK (>= 3.3.0) +- Dart SDK (>= 3.8.1) +- Git +- A code editor (VS Code, Android Studio, etc.) + +**For Native Platform Development**: +- Rust toolchain (`rustup` + `cargo`) +- Platform-specific tools: + - **Android**: JDK 11, Android SDK, NDK + - **iOS/macOS**: Xcode, CocoaPods + - **Windows**: Visual Studio Build Tools (C++) + - **Linux**: build-essential, clang, pkg-config + +**For Web Platform Development**: +- No Rust required! +- Just Flutter and Dart + +### Initial Setup + +```bash +# 1. Clone your fork +git clone https://github.com/YOUR_USERNAME/convex_flutter.git +cd convex_flutter + +# 2. Install Rust (skip if only working on web) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +source $HOME/.cargo/env + +# 3. Install dependencies +flutter pub get + +# 4. Run example app +cd example +flutter pub get +flutter run -d chrome # For web +# OR +flutter run -d macos # For native +``` + +### Setting Up for Development + +```bash +# Run flutter_rust_bridge code generation (if modifying Rust code) +cd rust +flutter_rust_bridge_codegen \ + --rust-input src/lib.rs \ + --dart-output ../lib/src/rust/lib.dart + +# Format Dart code +dart format . + +# Format Rust code +cd rust +cargo fmt + +# Analyze Dart code +flutter analyze + +# Run tests +flutter test +``` + +--- + +## Project Structure + +``` +convex_flutter/ +├── lib/ # Dart source code +│ ├── convex_flutter.dart # Public API exports +│ ├── src/ +│ │ ├── convex_client.dart # Main client (platform-agnostic) +│ │ ├── impl/ +│ │ │ ├── convex_client_web.dart # Web implementation (pure Dart) +│ │ │ └── convex_client_native.dart # Native implementation (FFI) +│ │ ├── rust/ # Generated FFI bindings +│ │ ├── convex_config.dart # Configuration class +│ │ ├── connection_status.dart +│ │ ├── app_lifecycle_*.dart +│ │ └── ... # Other Dart utilities +│ +├── rust/ # Rust source code (native platforms) +│ ├── Cargo.toml # Rust dependencies +│ ├── src/ +│ │ ├── lib.rs # Main Rust implementation +│ │ └── frb_generated.rs # Generated FFI code +│ └── target/ # Build artifacts +│ +├── example/ # Example Flutter app +│ ├── lib/main.dart # Example app code +│ ├── android/ # Android configuration +│ ├── ios/ # iOS configuration +│ ├── macos/ # macOS configuration +│ ├── web/ # Web configuration +│ └── ... +│ +├── test/ # Unit tests +├── ARCHITECTURE.md # Architecture documentation +├── PLATFORM_CONFIGURATION.md # Platform setup guide +├── CHANGELOG.md # Version history +└── README.md # Main documentation +``` + +--- + +## Making Changes + +### Branching Strategy + +- `main` - Stable release branch +- `develop` - Development branch (if used) +- `feature/*` - New features +- `fix/*` - Bug fixes +- `docs/*` - Documentation improvements +- `refactor/*` - Code refactoring + +### Commit Messages + +Write clear, descriptive commit messages following this format: + +``` +type(scope): Brief description + +Detailed explanation of changes (optional) + +Fixes #issue_number (if applicable) +``` + +**Types**: +- `feat`: New feature +- `fix`: Bug fix +- `docs`: Documentation changes +- `style`: Code style changes (formatting, etc.) +- `refactor`: Code refactoring +- `test`: Adding or updating tests +- `chore`: Maintenance tasks + +**Examples**: +``` +feat(web): Add web platform support with pure Dart implementation + +Implemented Convex WebSocket protocol in pure Dart for web platform. +Includes UUID generation, protocol messages, and subscription handling. + +Fixes #123 +``` + +``` +fix(macos): Add missing network entitlements + +Added com.apple.security.network.client entitlement to fix +WebSocket connection issues on macOS. + +Fixes #456 +``` + +--- + +## Testing + +### Running Tests + +```bash +# Run all tests +flutter test + +# Run specific test file +flutter test test/convex_client_test.dart + +# Run tests with coverage +flutter test --coverage +``` + +### Manual Testing + +**Web Platform**: +```bash +cd example +flutter run -d chrome +# Test all features in the browser +``` + +**Native Platforms**: +```bash +cd example + +# macOS +flutter run -d macos + +# iOS (requires macOS + Xcode) +flutter run -d ios + +# Android (requires Android device/emulator) +flutter run -d android +``` + +### Test Checklist for Pull Requests + +Before submitting a PR, verify: + +- [ ] All existing tests pass +- [ ] New features have tests +- [ ] Manual testing completed on relevant platforms: + - [ ] Web (if web-related changes) + - [ ] At least one native platform (if native changes) +- [ ] No breaking changes (or clearly documented) +- [ ] Documentation updated (if API changes) +- [ ] CHANGELOG.md updated (for notable changes) + +--- + +## Submitting Changes + +### Pull Request Process + +1. **Update Documentation**: If you changed APIs, update: + - README.md + - Inline code documentation + - ARCHITECTURE.md (if architectural changes) + - PLATFORM_CONFIGURATION.md (if platform-specific changes) + +2. **Update CHANGELOG.md**: Add entry under "Unreleased" section: + ```markdown + ## Unreleased + + ### New Features + - Your feature description + + ### Bug Fixes + - Your fix description + ``` + +3. **Create Pull Request** with: + - **Clear title**: `feat: Add web platform support` + - **Description**: Explain what, why, and how + - **Issue reference**: `Fixes #123` or `Closes #456` + - **Screenshots/GIFs**: For UI changes + - **Testing notes**: How you tested the changes + - **Breaking changes**: Clearly marked if any + +4. **Respond to Reviews**: Address feedback promptly and respectfully + +5. **CI/CD Checks**: Ensure all automated checks pass + +### PR Template + +```markdown +## Description +Brief description of changes + +## Type of Change +- [ ] Bug fix (non-breaking change fixing an issue) +- [ ] New feature (non-breaking change adding functionality) +- [ ] Breaking change (fix or feature that breaks existing functionality) +- [ ] Documentation update + +## Related Issue +Fixes #(issue number) + +## How Has This Been Tested? +Describe testing process + +## Platforms Tested +- [ ] Web +- [ ] Android +- [ ] iOS +- [ ] macOS +- [ ] Windows +- [ ] Linux + +## Checklist +- [ ] My code follows the project's style guidelines +- [ ] I have performed a self-review +- [ ] I have commented my code where needed +- [ ] I have updated documentation +- [ ] I have added tests +- [ ] All tests pass locally +- [ ] I have updated CHANGELOG.md +``` + +--- + +## Style Guidelines + +### Dart Code Style + +Follow the [Dart Style Guide](https://dart.dev/guides/language/effective-dart/style): + +```bash +# Format code +dart format . + +# Analyze code +flutter analyze +``` + +**Key conventions**: +- Use `lowerCamelCase` for variables, methods, parameters +- Use `UpperCamelCase` for classes, enums, typedefs +- Prefer `final` over `var` +- Use trailing commas for better formatting +- Document public APIs with `///` doc comments + +**Example**: +```dart +/// Executes a Convex query with the given [name] and [args]. +/// +/// Returns a JSON string containing the query result. +/// Throws [TimeoutException] if the operation exceeds [operationTimeout]. +/// +/// Example: +/// ```dart +/// final result = await client.query('users:list', {'limit': '10'}); +/// final users = jsonDecode(result); +/// ``` +Future query(String name, Map args) async { + // Implementation +} +``` + +### Rust Code Style + +Follow the [Rust Style Guide](https://doc.rust-lang.org/beta/style-guide/): + +```bash +cd rust +cargo fmt # Format +cargo clippy # Lint +``` + +**Key conventions**: +- Use `snake_case` for functions, variables +- Use `UpperCamelCase` for types, traits +- Document public items with `///` comments +- Use `Result` for error handling +- Prefer pattern matching over if/else + +--- + +## Platform-Specific Contributions + +### Working on Web Platform + +**File**: `lib/src/impl/convex_client_web.dart` + +**Dependencies**: `package:web`, `package:http` + +**No Rust required!** + +**Testing**: +```bash +flutter run -d chrome +flutter test # Tests run on VM, but web code path is used +``` + +**Key areas**: +- WebSocket protocol implementation +- UUID generation +- Connection state management +- Subscription handling + +### Working on Native Platforms + +**File**: `rust/src/lib.rs`, `lib/src/impl/convex_client_native.dart` + +**Dependencies**: Rust toolchain, `flutter_rust_bridge` + +**Testing**: Requires platform-specific setup (Xcode for iOS/macOS, Android SDK for Android, etc.) + +**Key areas**: +- FFI bridge between Dart and Rust +- Rust wrapper around Convex SDK +- Native platform configurations (entitlements, permissions) + +### Adding New Features + +When adding features: + +1. **Implement for both platforms** (web + native) if applicable +2. **Maintain API parity** between platforms +3. **Add tests** for both implementations +4. **Update documentation** in README.md +5. **Add platform-specific notes** in PLATFORM_CONFIGURATION.md if needed + +--- + +## Questions? + +- **Issues**: https://github.com/jkuldev/convex_flutter/issues +- **Discussions**: https://github.com/jkuldev/convex_flutter/discussions +- **Email**: Contact maintainers at jkuldev.com + +--- + +## License + +By contributing to `convex_flutter`, you agree that your contributions will be licensed under the MIT License. + +--- + +**Thank you for contributing to convex_flutter! 🎉** diff --git a/third_party/convex_flutter/ICARUS_PATCH.md b/third_party/convex_flutter/ICARUS_PATCH.md new file mode 100644 index 00000000..dc5fc8c8 --- /dev/null +++ b/third_party/convex_flutter/ICARUS_PATCH.md @@ -0,0 +1,36 @@ +# Icarus convex_flutter patch + +Source: the published `convex_flutter` 3.0.1 package. + +Icarus pins the package's native `convex` Rust client to 0.10.4. The published +package lock selected 0.10.2. Convex Rust 0.10.3 introduced the reconnect-state +repair and auth-token callback used to restore authenticated state after a +WebSocket reconnect; 0.10.4 includes that repair plus a subscription leak fix. + +The package's hand-written Rust auth adapter now gives the Dart token callback +to `ConvexClient.set_auth_callback`. That keeps token refresh in the same +upstream state machine that replays subscriptions and mutations after a socket +reconnect. The old adapter owned a separate expiry timer and called static +`set_auth`, which could leave the client disconnected after the server rejected +an expired token. + +Auth handles also carry an internal generation. Disposal only clears auth when +the handle still owns the current generation, so a delayed cancellation from a +replaced handle cannot erase the fresh callback. + +The native manual reconnect API calls the Icarus-patched `convex` 0.10.4 crate +in `../convex_rs`. It waits for a real connecting-to-connected transition; the +published package method only ran its configured health query. + +Generated Dart and Rust bridge files are never hand-edited. They are regenerated +from `rust/src/lib.rs` with `flutter_rust_bridge_codegen` 2.11.1. Hand-written +changes are limited to the Dart client implementation, `rust/src/lib.rs`, and +the local `convex` dependency in `rust/Cargo.toml`. + +```sh +cd third_party/convex_flutter/rust +flutter_rust_bridge_codegen generate +``` + +This directory can be removed once a published `convex_flutter` release uses a +Convex Rust client with the same fixes and passes the Icarus auth gauntlet. diff --git a/third_party/convex_flutter/LICENSE b/third_party/convex_flutter/LICENSE new file mode 100644 index 00000000..f70f0b3f --- /dev/null +++ b/third_party/convex_flutter/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 jkuldev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/third_party/convex_flutter/MIGRATION_v3.md b/third_party/convex_flutter/MIGRATION_v3.md new file mode 100644 index 00000000..23a53bba --- /dev/null +++ b/third_party/convex_flutter/MIGRATION_v3.md @@ -0,0 +1,403 @@ +# Migration Guide: v2.x → v3.0.0 + +## Overview + +**Good news: v3.0.0 has ZERO breaking changes!** 🎉 + +This is a feature release that adds web platform support while maintaining 100% backward compatibility with v2.x. Your existing code will continue to work without modifications. + +## What's New in v3.0.0 + +### Major New Features + +1. **Web Platform Support** 🌐 + - Full web platform support with pure Dart implementation + - No Rust required for web builds + - Same API works on web and native platforms + +2. **Platform-Specific Implementations** + - Automatic platform selection via conditional imports + - Web: Pure Dart WebSocket client + - Native: FFI + Rust SDK (unchanged) + +3. **Critical Bug Fixes** + - Fixed macOS connection issues (network entitlements) + - Fixed Android missing INTERNET permission + - Fixed Rust rustls CryptoProvider error + +## Migration Steps + +### Step 1: Update Package Version + +Update your `pubspec.yaml`: + +```yaml +dependencies: + convex_flutter: ^3.0.0 # Update from ^2.2.0 +``` + +Then run: + +```bash +flutter pub upgrade convex_flutter +``` + +### Step 2: Platform Configuration (One-Time Setup) + +#### macOS Apps + +Add network entitlements to **both** files: + +**macos/Runner/DebugProfile.entitlements**: +```xml +com.apple.security.network.client + +com.apple.security.network.server + +``` + +**macos/Runner/Release.entitlements**: +```xml +com.apple.security.network.client + +com.apple.security.network.server + +``` + +#### Android Apps + +Add internet permission to **android/app/src/main/AndroidManifest.xml**: + +```xml + + + + +``` + +#### iOS, Windows, Linux Apps + +No changes required - these platforms work out of the box. + +### Step 3: Test Your App + +```bash +# Test on your target platforms +flutter run -d chrome # Web +flutter run -d macos # macOS +flutter run -d android # Android +flutter run -d ios # iOS +``` + +### Step 4: Build for Web (New!) + +You can now build your app for web: + +```bash +flutter build web +``` + +**No Rust toolchain required for web builds!** + +--- + +## Code Changes Required + +### None! ✅ + +Your existing v2.x code will continue to work without modifications. The API is 100% compatible. + +**Example - This code works identically in v2.x and v3.0.0**: + +```dart +import 'package:convex_flutter/convex_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialization - no changes + await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: 'https://my-app.convex.cloud', + clientId: 'flutter-app-1.0', + ), + ); + + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + final client = ConvexClient.instance; + + return MaterialApp( + home: Scaffold( + body: StreamBuilder( + stream: client.connectionState, + builder: (context, snapshot) { + // Works on both web and native! + final isConnected = snapshot.data == WebSocketConnectionState.connected; + return Text(isConnected ? 'Connected' : 'Connecting...'); + }, + ), + ), + ); + } +} +``` + +--- + +## Platform-Specific Differences + +### Web vs Native + +While the API is identical, there are minor implementation differences: + +| Feature | Web | Native | +|---------|-----|--------| +| WebSocket Source | Browser WebSocket API | Convex Rust SDK | +| Implementation | Pure Dart | FFI + Rust | +| Build Requirements | None | Rust toolchain | +| Performance | Excellent | Excellent | +| API | **Identical** | **Identical** | + +**Bottom Line**: Your code doesn't need to know which platform it's running on. The package handles it automatically. + +--- + +## Deprecations + +No APIs were deprecated in v3.0.0. All v2.x methods remain available. + +--- + +## New Capabilities + +### Web Platform Support + +You can now target web alongside mobile and desktop: + +```bash +# Web (new in v3.0.0) +flutter build web + +# Mobile (existing) +flutter build apk +flutter build ios + +# Desktop (existing) +flutter build macos +flutter build windows +flutter build linux +``` + +### Cross-Platform Example App + +The example app now works on **all platforms**: + +```bash +cd example + +# Run on any platform +flutter run -d chrome # Web +flutter run -d macos # macOS +flutter run -d ios # iOS Simulator +flutter run -d android # Android Emulator +flutter run -d windows # Windows +flutter run -d linux # Linux +``` + +--- + +## Troubleshooting + +### macOS: Stuck in "Connecting" + +**Symptom**: App builds successfully but connection state never changes from "connecting" + +**Solution**: Add network entitlements (see Step 2 above) + +**Verify**: +```bash +# Check DebugProfile.entitlements contains: +grep "network.client" macos/Runner/DebugProfile.entitlements +``` + +### Android: Network Security Exception + +**Symptom**: App crashes with `SocketException: Permission denied` + +**Solution**: Add INTERNET permission (see Step 2 above) + +**Verify**: +```bash +# Check AndroidManifest.xml contains: +grep "INTERNET" android/app/src/main/AndroidManifest.xml +``` + +### Web: Build Errors + +**Symptom**: Build fails when targeting web + +**Solution**: Ensure Flutter web support is enabled: + +```bash +flutter config --enable-web +flutter clean +flutter pub get +flutter build web +``` + +### Rust CryptoProvider Error + +**Symptom**: `Could not automatically determine the process-level CryptoProvider` + +**Solution**: This was fixed in the package. Upgrade to v3.0.0: + +```bash +flutter pub upgrade convex_flutter +``` + +--- + +## Performance Considerations + +### Build Times + +**Web**: Faster builds (no Rust compilation) +```bash +# First build +flutter build web # ~1-2 minutes + +# Subsequent builds +flutter build web # ~30-60 seconds +``` + +**Native**: Unchanged from v2.x +```bash +# First build (includes Rust compilation) +flutter build apk # ~3-5 minutes + +# Subsequent builds (Rust cached) +flutter build apk # ~1-2 minutes +``` + +### Runtime Performance + +Both web and native implementations have excellent performance: + +- **Web**: Leverages browser's native WebSocket engine +- **Native**: Uses compiled Rust code + +**No performance degradation** compared to v2.x. + +--- + +## Testing Recommendations + +### Minimum Testing + +Before deploying v3.0.0, test on: + +- [ ] Your primary target platform (web, iOS, Android, etc.) +- [ ] Connection establishment +- [ ] Query execution +- [ ] Mutation execution +- [ ] Subscriptions (if you use them) +- [ ] Authentication (if you use it) + +### Comprehensive Testing + +For production apps, also test: + +- [ ] Connection state monitoring +- [ ] Reconnection after network interruption +- [ ] App backgrounding/foregrounding +- [ ] Hot reload (development) +- [ ] Release builds + +--- + +## Rollback Plan + +If you encounter issues with v3.0.0, you can easily rollback: + +```yaml +# pubspec.yaml +dependencies: + convex_flutter: ^2.2.0 # Rollback to v2.2.0 +``` + +Then run: + +```bash +flutter pub downgrade convex_flutter +flutter clean +flutter pub get +``` + +**Note**: You'll lose web platform support and the bug fixes when rolling back. + +--- + +## Support + +If you encounter migration issues: + +1. **Check Documentation**: + - [PLATFORM_CONFIGURATION.md](PLATFORM_CONFIGURATION.md) - Platform setup guide + - [README.md](README.md) - Updated with v3.0.0 features + - [ARCHITECTURE.md](ARCHITECTURE.md) - Web implementation details + +2. **Search Issues**: https://github.com/jkuldev/convex_flutter/issues + +3. **Create New Issue**: https://github.com/jkuldev/convex_flutter/issues/new + - Include Flutter version, platform, and error details + +--- + +## Changelog + +For complete v3.0.0 changes, see [CHANGELOG.md](CHANGELOG.md#300). + +**Summary**: +- ✅ Web platform support (pure Dart) +- ✅ Fixed macOS network permissions +- ✅ Fixed Android INTERNET permission +- ✅ Fixed Rust rustls CryptoProvider +- ✅ Updated Convex SDK to 0.10.2 +- ✅ Zero breaking changes + +--- + +## Next Steps + +After migrating to v3.0.0: + +1. **Enable Web** (optional): + ```bash + flutter config --enable-web + flutter run -d chrome + ``` + +2. **Review New Documentation**: + - Platform-specific setup in PLATFORM_CONFIGURATION.md + - Web implementation details in ARCHITECTURE.md + +3. **Enjoy Multi-Platform Support**: Build your Convex Flutter app for web, mobile, and desktop! + +--- + +**Migration Difficulty**: ⭐ Very Easy (no code changes required) + +**Time Required**: 5-10 minutes (mostly platform configuration) + +**Risk Level**: 🟢 Low (backward compatible, easy rollback) + +--- + +**Questions?** Open an issue: https://github.com/jkuldev/convex_flutter/issues diff --git a/third_party/convex_flutter/PLATFORM_CONFIGURATION.md b/third_party/convex_flutter/PLATFORM_CONFIGURATION.md new file mode 100644 index 00000000..54f73e7c --- /dev/null +++ b/third_party/convex_flutter/PLATFORM_CONFIGURATION.md @@ -0,0 +1,264 @@ +# Platform Configuration Guide + +This guide explains the platform-specific configuration required for `convex_flutter` to work correctly on all supported platforms. + +## Quick Reference + +| Platform | Configuration Required | Auto-configured? | +|----------|----------------------|------------------| +| **Web** | None | ✅ Yes | +| **iOS** | None | ✅ Yes | +| **macOS** | Network entitlements | ❌ Manual setup required | +| **Android** | INTERNET permission | ❌ Manual setup required | +| **Windows** | None | ✅ Yes | +| **Linux** | None | ✅ Yes | + +--- + +## Platform-Specific Setup + +### macOS + +macOS apps use App Sandbox for security, which requires explicit network permissions. + +#### Required Files + +1. **DebugProfile.entitlements** (for debug builds) +2. **Release.entitlements** (for release builds) + +**Location**: `macos/Runner/` + +#### Configuration + +Add the following entitlements to **both** files: + +```xml + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + +``` + +#### Critical Permissions + +- `com.apple.security.network.client` - **Required** for outgoing WebSocket connections to Convex +- `com.apple.security.network.server` - **Required** for accepting incoming connections (if needed) +- `com.apple.security.app-sandbox` - Enables macOS App Sandbox +- `com.apple.security.cs.allow-jit` - Allows JIT compilation (required for Flutter) + +#### What Happens Without These? + +Without `com.apple.security.network.client`, your app will: +- Build and launch successfully +- Get stuck in "connecting" state forever +- Never establish WebSocket connection to Convex +- Show no error messages (silently blocked by macOS sandbox) + +--- + +### Android + +Android requires explicit permission for internet access. + +#### Required File + +**AndroidManifest.xml** + +**Location**: `android/app/src/main/AndroidManifest.xml` + +#### Configuration + +Add the INTERNET permission **inside the `` tag, before ``**: + +```xml + + + + +``` + +#### Permission Details + +- **Type**: Normal permission (auto-granted at install) +- **User prompt**: No - granted automatically +- **Required for**: All network operations (WebSocket, HTTP, etc.) + +#### What Happens Without This? + +Without `android.permission.INTERNET`, your app will: +- Build successfully +- Crash or fail when attempting network connections +- Show security exceptions in logs + +--- + +### iOS + +**No configuration required** ✅ + +iOS apps have network access by default unless explicitly restricted. `convex_flutter` works out of the box on iOS. + +**Note**: If you're using App Transport Security (ATS) customization, ensure your Convex backend URL is allowed. + +--- + +### Web + +**No configuration required** ✅ + +Web platform uses the browser's native WebSocket API, which inherits the browser's network permissions. Works automatically. + +**Technical Details**: +- Uses pure Dart implementation (no FFI) +- Leverages `package:web` for WebSocket access +- Respects browser CORS and security policies + +--- + +### Windows + +**No configuration required** ✅ + +Windows desktop apps have network access by default. The Windows Firewall may prompt users to allow network access on first run (standard Windows behavior). + +--- + +### Linux + +**No configuration required** ✅ + +Linux desktop apps have network access by default. No special permissions or configuration needed. + +--- + +## Troubleshooting + +### macOS: Stuck in "Connecting" State + +**Symptoms**: +- App builds and launches +- Connection state shows "connecting" forever +- No error messages + +**Solution**: +1. Check `macos/Runner/DebugProfile.entitlements` +2. Ensure `com.apple.security.network.client` is present +3. Check `macos/Runner/Release.entitlements` for release builds +4. Clean build: `flutter clean && flutter run` + +### Android: Network Security Exception + +**Symptoms**: +- App crashes on connection attempt +- Error: `java.net.SocketException: Permission denied` +- Logs show security policy violation + +**Solution**: +1. Check `android/app/src/main/AndroidManifest.xml` +2. Add `` +3. Rebuild: `flutter clean && flutter run` + +### Rust Panic: CryptoProvider Error + +**Symptoms**: +- Error: `Could not automatically determine the process-level CryptoProvider` +- Panic in rustls library + +**Solution**: +This affects the package itself, not user apps. If you encounter this: +1. Check `rust/Cargo.toml` +2. Ensure convex dependency does NOT have `default-features = false` +3. Correct format: `convex = { version = "0.10", features = ["rustls-tls-webpki-roots"] }` + +--- + +## Integration Checklist + +When integrating `convex_flutter` into your Flutter app, verify: + +- [ ] **macOS**: Added network entitlements to both DebugProfile and Release entitlements +- [ ] **Android**: Added INTERNET permission to AndroidManifest.xml +- [ ] **iOS**: No action required (works by default) +- [ ] **Web**: No action required (works by default) +- [ ] **Windows**: No action required (works by default) +- [ ] **Linux**: No action required (works by default) + +--- + +## Why These Permissions Are Needed + +### macOS App Sandbox + +macOS uses a security feature called "App Sandbox" that restricts app capabilities by default. Apps must explicitly declare what they need to access (network, files, camera, etc.). This is a macOS platform requirement, not specific to `convex_flutter`. + +**Learn more**: [Apple: App Sandbox](https://developer.apple.com/documentation/security/app_sandbox) + +### Android Permission System + +Android uses a permission-based security model where apps must declare all permissions they'll use. Network access is considered a "normal" permission (auto-granted) but must still be declared in the manifest. + +**Learn more**: [Android: App Permissions](https://developer.android.com/guide/topics/permissions/overview) + +--- + +## Example Apps + +The `example/` directory in this repository demonstrates proper configuration for all platforms: + +``` +example/ +├── android/app/src/main/AndroidManifest.xml # INTERNET permission +├── ios/ # No config needed +├── macos/Runner/ +│ ├── DebugProfile.entitlements # Network entitlements +│ └── Release.entitlements # Network entitlements +├── web/ # No config needed +├── windows/ # No config needed +└── linux/ # No config needed +``` + +--- + +## Platform Support Matrix + +| Platform | SDK Version | Network Config | Rust Required | +|----------|-------------|----------------|---------------| +| Web | Any | None | No | +| iOS | iOS 12+ | None | Yes (build-time) | +| macOS | macOS 10.14+ | Entitlements | Yes (build-time) | +| Android | API 21+ | Manifest | Yes (build-time) | +| Windows | Windows 7+ | None | Yes (build-time) | +| Linux | Any | None | Yes (build-time) | + +**Note**: Rust is required at **build time** for native platforms (iOS, macOS, Android, Windows, Linux) but **not required** for web platform. + +--- + +## Questions or Issues? + +If you encounter platform-specific issues not covered here: + +1. Check the [example app configuration](example/) +2. Search [GitHub issues](https://github.com/get-convex/convex_flutter/issues) +3. Create a new issue with: + - Platform and version + - Flutter doctor output + - Relevant configuration files + - Error messages or logs + +--- + +**Last Updated**: 2026-01-10 +**Package Version**: 3.0.0 diff --git a/third_party/convex_flutter/PUB_DEPLOY_GUIDE.md b/third_party/convex_flutter/PUB_DEPLOY_GUIDE.md new file mode 100644 index 00000000..e8e9fa2b --- /dev/null +++ b/third_party/convex_flutter/PUB_DEPLOY_GUIDE.md @@ -0,0 +1,332 @@ +# Pub.dev Deployment Guide - convex_flutter v2.2.0 + +## Pre-Deployment Checklist + +### ✅ All Requirements Met + +- [x] Version bumped to 2.2.0 in pubspec.yaml +- [x] CHANGELOG.md updated with detailed v2.2.0 release notes +- [x] README.md updated with all new features documented +- [x] LICENSE file present (MIT License) +- [x] Package validation passed (`flutter pub publish --dry-run`) +- [x] All commits pushed to GitHub +- [x] Example app working and demonstrating all features + +### Package Information + +**Package Name**: `convex_flutter` +**Version**: `2.2.0` +**Repository**: https://github.com/jkuldev/convex_flutter +**Homepage**: https://jkuldev.com +**License**: MIT +**Package Size**: 328 KB (compressed) + +## What's New in v2.2.0 + +### Major Features + +1. **Real-Time WebSocket Connection State Monitoring** + - `connectionState` stream for real-time updates + - `currentConnectionState` getter for sync access + - `isConnected` boolean getter + - Automatic state transitions (Connecting → Connected) + +2. **Critical Bug Fixes** + - Fixed race condition in WebSocket connection initialization + - Fixed connection state stuck on "connecting" + - Improved connection reliability + +3. **Enhanced Documentation** + - Health check query setup guide (TypeScript + Dart) + - Comprehensive usage examples with StreamBuilder + - Clear optional vs required patterns + - Step-by-step tutorials + +### API Additions + +```dart +// New in v2.2.0 +Stream connectionState +WebSocketConnectionState currentConnectionState +bool isConnected +``` + +### Deprecated APIs + +```dart +// Deprecated (still works, but use connectionState instead) +Future checkConnection() +``` + +## Deployment Steps + +### Step 1: Final Validation + +Run the dry-run command to verify everything is ready: + +```bash +flutter pub publish --dry-run +``` + +**Expected Output:** +- Package validation passed +- 0 warnings +- 1 hint about version increment (this is fine) +- Total compressed size: ~328 KB + +### Step 2: Verify Git Status + +Make sure all changes are committed: + +```bash +git status +git log --oneline -5 +``` + +**Expected Commits on Branch:** +``` +5218369 chore: Bump version to 2.2.0 for pub.dev release +ce51ed4 docs: Clarify health check is optional but recommended +0e7484c docs: Recommend dedicated health check query (health:ping) +8aa9d6e example updated +3c34666 feat: Add real-time WebSocket connection state monitoring (v2.2.0) +``` + +### Step 3: Push to GitHub + +Push the branch to GitHub: + +```bash +git push -u origin fix/websocket-connection-state-v2.2.0 +``` + +**Or if using HTTPS:** +```bash +git remote set-url origin https://github.com/jkuldev/convex_flutter.git +git push -u origin fix/websocket-connection-state-v2.2.0 +``` + +### Step 4: Merge to Main + +Option A - Via GitHub Pull Request: +1. Go to https://github.com/jkuldev/convex_flutter/pulls +2. Create Pull Request from `fix/websocket-connection-state-v2.2.0` +3. Review changes +4. Merge to main +5. Pull main locally: `git checkout main && git pull` + +Option B - Local Merge: +```bash +git checkout main +git merge fix/websocket-connection-state-v2.2.0 +git push origin main +``` + +### Step 5: Create Git Tag (Recommended) + +```bash +git tag v2.2.0 +git push origin v2.2.0 +``` + +Or create annotated tag with release notes: +```bash +git tag -a v2.2.0 -m "Release v2.2.0: WebSocket Connection State Monitoring + +- Real-time WebSocket connection state streams +- Fixed critical race condition in connection initialization +- Fixed connection state stuck on 'connecting' +- Enhanced documentation with health check guide +- New connection state APIs +- Comprehensive example app with 5 screens" + +git push origin v2.2.0 +``` + +### Step 6: Publish to pub.dev + +**IMPORTANT**: Make sure you're on the main branch with the latest changes: + +```bash +git checkout main +git pull +``` + +**Publish the package:** + +```bash +flutter pub publish +``` + +**The command will:** +1. Validate the package +2. Show a preview of what will be published +3. Ask for confirmation +4. Upload to pub.dev + +**You'll need:** +- A verified pub.dev account +- Access credentials (you'll be prompted to login) + +**After Publishing:** +- Package will be available at: https://pub.dev/packages/convex_flutter +- Version 2.2.0 will appear within minutes + +### Step 7: Verify Publication + +After publishing, verify on pub.dev: + +1. Visit: https://pub.dev/packages/convex_flutter +2. Check version shows as 2.2.0 +3. Verify README displays correctly +4. Check CHANGELOG is visible +5. Confirm example tab shows code +6. Review package score (should be 130+/140) + +## Post-Deployment + +### Create GitHub Release + +1. Go to: https://github.com/jkuldev/convex_flutter/releases/new +2. Choose tag: `v2.2.0` +3. Release title: `v2.2.0 - WebSocket Connection State Monitoring` +4. Description: Copy from CHANGELOG.md or use: + +```markdown +## 🎉 convex_flutter v2.2.0 + +### New Features +- **Real-Time WebSocket Connection State**: Monitor connection status via reactive streams +- `connectionState` stream for real-time updates +- `currentConnectionState` and `isConnected` getters +- Automatic state transitions + +### Bug Fixes +- Fixed critical race condition in WebSocket connection initialization +- Fixed connection state stuck on "connecting" +- Improved connection reliability + +### Documentation +- Comprehensive health check guide with TypeScript examples +- WebSocket connection state usage examples +- Enhanced example app with 5 demonstration screens + +[View Full Changelog](https://github.com/jkuldev/convex_flutter/blob/main/CHANGELOG.md) + +**Install:** +```yaml +dependencies: + convex_flutter: ^2.2.0 +``` +``` + +5. Publish release + +### Announce (Optional) + +Consider announcing the release: +- Twitter/X +- LinkedIn +- Flutter community Discord/Slack +- Reddit r/FlutterDev +- Dev.to blog post + +## Troubleshooting + +### Issue: "Unauthorized" error when publishing + +**Solution:** +```bash +# Login to pub.dev +dart pub login + +# Then try publishing again +flutter pub publish +``` + +### Issue: "Version already exists" + +**Solution:** +- Version 2.2.0 is already published +- Increment version to 2.2.1 or 2.3.0 +- Update CHANGELOG.md +- Commit and try again + +### Issue: Package validation fails + +**Solution:** +```bash +# Run dry-run to see specific errors +flutter pub publish --dry-run + +# Fix any errors shown +# Common issues: +# - Missing README.md +# - Missing CHANGELOG.md +# - Invalid pubspec.yaml +# - Missing LICENSE +``` + +### Issue: Git push fails (permission denied) + +**Solution:** +```bash +# Use HTTPS instead of SSH +git remote set-url origin https://github.com/jkuldev/convex_flutter.git + +# Or set up SSH keys: +ssh-keygen -t ed25519 -C "your_email@example.com" +# Add to GitHub: https://github.com/settings/keys +``` + +## Rollback Plan + +If issues are discovered after publishing: + +### Option 1: Publish Hotfix (Recommended) + +```bash +# Fix the issue +# Update version to 2.2.1 +# Update CHANGELOG.md +git commit -am "fix: Critical issue in v2.2.0" +flutter pub publish +``` + +### Option 2: Retract Version (Last Resort) + +```bash +# This marks the version as broken +dart pub publisher retract convex_flutter 2.2.0 +``` + +**Note:** Retraction doesn't delete the package, it just warns users. + +## Support After Release + +Monitor for issues: +- GitHub Issues: https://github.com/jkuldev/convex_flutter/issues +- pub.dev comments +- Stack Overflow questions tagged `convex-flutter` + +## Success Criteria + +✅ Package published successfully +✅ Version 2.2.0 visible on pub.dev +✅ Documentation renders correctly +✅ Example app accessible via pub.dev +✅ Package score 130+/140 +✅ GitHub release created with tag v2.2.0 +✅ All features working as documented + +## Contact + +If you encounter any issues during deployment: +- Check pub.dev documentation: https://dart.dev/tools/pub/publishing +- Flutter pub publishing guide: https://flutter.dev/docs/development/packages-and-plugins/developing-packages + +--- + +**Ready to publish!** 🚀 + +Run: `flutter pub publish` diff --git a/third_party/convex_flutter/README.md b/third_party/convex_flutter/README.md new file mode 100644 index 00000000..ce0eed95 --- /dev/null +++ b/third_party/convex_flutter/README.md @@ -0,0 +1,493 @@ +# Convex Flutter + +

+ + + + + + + + + +
Home ScreenMessaging Screen
Home ScreenReal-time Messaging
+

+ +A Flutter plugin for integrating with the Convex backend. It provides a simple Dart API over the Convex Rust core to run queries, mutations, and actions, and to subscribe to real-time updates. + +This package wraps the [Convex Rust library](https://github.com/get-convex/convex-rs) and exposes a Flutter-friendly interface. + +## Features + +- Real-time subscriptions to Convex queries +- Simple Dart API for queries, mutations, and actions +- Authentication with automatic token refresh +- Auth state stream for reactive UI updates +- **WebSocket connection state** - Real-time connection status monitoring via streams +- **Operation timeouts** - Configurable timeout for all queries, mutations, and actions +- **Lifecycle monitoring** - Stream of app lifecycle events (foreground/background) +- **Connection management** - Manual connection checking and reconnect functionality +- **Singleton pattern** - Access client anywhere via `ConvexClient.instance` +- **Multi-platform support** - Works on Web (pure Dart), Android, iOS, macOS, Windows, and Linux (FFI) + +## Installation + +Add the package to your Flutter project: + +```bash +flutter pub add convex_flutter +``` + +That's it! The health check query mentioned below is optional - you can start using the SDK immediately without it. + +## Platform Configuration + +**Important**: Some platforms require additional configuration for network access. This is a one-time setup. + +| Platform | Configuration Required | +|----------|------------------------| +| **Web** | ✅ None - works automatically | +| **iOS** | ✅ None - works automatically | +| **macOS** | ⚠️ **Network entitlements required** | +| **Android** | ⚠️ **INTERNET permission required** | +| **Windows** | ✅ None - works automatically | +| **Linux** | ✅ None - works automatically | + +### Quick Setup + +**macOS**: Add network entitlements to `macos/Runner/DebugProfile.entitlements` and `Release.entitlements`: + +```xml +com.apple.security.network.client + +com.apple.security.network.server + +``` + +**Android**: Add internet permission to `android/app/src/main/AndroidManifest.xml`: + +```xml + +``` + +**📖 See [PLATFORM_CONFIGURATION.md](PLATFORM_CONFIGURATION.md) for complete setup instructions and troubleshooting.** + +## Requirements + +- Dart SDK >= 3.8.1 and Flutter >= 3.3.0 +- **Web platform**: No additional requirements (uses pure Dart implementation) +- **Native platforms** (Android, iOS, macOS, Windows, Linux): + - Rust toolchain (rustup + cargo) for building native code + - Platform-specific toolchains: + - Android: JDK 11 and Android SDK/NDK + - iOS/macOS: Xcode and CocoaPods + - Windows: Visual Studio Build Tools (C++) + - Linux: clang, pkg-config, and build essentials + +## Quick start + +### Optional: Create a Health Check Query (Recommended) + +For connection monitoring and health checks, it's recommended to create a lightweight health check query in your Convex backend. This is **optional** but provides a clean way to verify connectivity without side effects. + +Create a file `convex/health.ts` in your Convex backend: + +```typescript +// convex/health.ts +import { query } from "./_generated/server"; + +export const ping = query({ + args: {}, + handler: async () => { + return "ok"; + }, +}); +``` + +This creates a lightweight endpoint at `health:ping` that you can use for connection health checks. It has no side effects and returns instantly. + +### Initialize the Client + +```dart +import 'package:convex_flutter/convex_flutter.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Initialize the client once (singleton) + await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: 'https://my-app.convex.cloud', + clientId: 'flutter-app-1.0', + operationTimeout: Duration(seconds: 30), // Optional, defaults to 30s + healthCheckQuery: 'health:ping', // Optional, for connection checks (requires health.ts) + ), + ); + + runApp(MyApp()); +} + +// Access the client anywhere in your app +void example() async { + final client = ConvexClient.instance; + + // Optional: authenticate (see Authentication section below) + await client.setAuth(token: 'YOUR_AUTH_TOKEN'); + + // Query (with timeout) + try { + final users = await client.query('users:list', {'limit': '10'}); + print('Users: $users'); + } on TimeoutException { + print('Connection timeout!'); + } + + // Subscribe to real-time updates + final sub = await client.subscribe( + name: 'messages:list', + args: {}, + onUpdate: (value) => print('Update: $value'), + onError: (message, value) => print('Error: $message ${value ?? ''}'), + ); + + // Mutation + await client.mutation( + name: 'messages:send', + args: {'body': 'Hello!', 'author': 'User123'}, + ); + + // Action (if you have actions defined) + // final res = await client.action(name: 'files:upload', args: {...}); + + // Later, when done + sub.cancel(); +} +``` + +## Authentication + +The SDK provides comprehensive authentication support for Convex backends. + +### Simple Token Authentication + +For basic scenarios or testing, set a static JWT token: + +```dart +// Set authentication +await client.setAuth(token: 'your-jwt-token'); + +// Clear authentication +await client.setAuth(token: null); +``` + +### Automatic Token Refresh (Recommended) + +For production apps, use `setAuthWithRefresh` which automatically refreshes tokens 60 seconds before they expire: + +```dart +final authHandle = await client.setAuthWithRefresh( + fetchToken: () async { + // Return JWT from your auth provider (Firebase, Clerk, Auth0, etc.) + return await FirebaseAuth.instance.currentUser?.getIdToken(); + }, + onAuthChange: (isAuthenticated) { + print('Auth state: $isAuthenticated'); + }, +); + +// When signing out, dispose the auth handle +authHandle.dispose(); +``` + +### Auth State Stream + +Listen to authentication state changes reactively: + +```dart +client.authState.listen((isAuthenticated) { + setState(() => _isLoggedIn = isAuthenticated); +}); +``` + +### Sync Auth Check + +Check current auth state synchronously: + +```dart +if (client.isAuthenticated) { + // User is authenticated +} +``` + +### Clear Authentication + +Clear auth and stop any running token refresh: + +```dart +await client.clearAuth(); +``` + +## Connection Management + +The SDK provides tools for managing connection state and handling network interruptions. + +### Operation Timeouts + +All queries, mutations, and actions have configurable timeouts (default: 30 seconds): + +```dart +await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: 'https://my-app.convex.cloud', + operationTimeout: Duration(seconds: 45), // Custom timeout + ), +); + +// Operations will throw TimeoutException if they exceed the timeout +try { + await ConvexClient.instance.query('slowQuery', {}); +} on TimeoutException { + print('Operation timed out!'); +} +``` + +### Real-Time WebSocket Connection State (Recommended) + +Monitor WebSocket connection state in real-time using streams. This is the recommended approach for connection monitoring: + +```dart +// Listen to connection state changes +ConvexClient.instance.connectionState.listen((state) { + switch (state) { + case WebSocketConnectionState.connected: + print('WebSocket connected!'); + // Update UI, enable features + break; + case WebSocketConnectionState.connecting: + print('WebSocket connecting...'); + // Show loading indicator + break; + } +}); + +// Or use in a StreamBuilder for reactive UI +StreamBuilder( + stream: ConvexClient.instance.connectionState, + initialData: ConvexClient.instance.currentConnectionState, + builder: (context, snapshot) { + final state = snapshot.data ?? WebSocketConnectionState.connecting; + final isConnected = state == WebSocketConnectionState.connected; + + return Chip( + avatar: Icon(isConnected ? Icons.cloud_done : Icons.cloud_sync), + label: Text(isConnected ? 'Connected' : 'Connecting'), + backgroundColor: isConnected ? Colors.green : Colors.orange, + ); + }, +) + +// Synchronous access to current state +if (ConvexClient.instance.isConnected) { + // WebSocket is connected +} +``` + +**Features:** +- Real-time state updates via Stream (no polling needed) +- Automatic state transitions when WebSocket connects/disconnects +- Synchronous getter for immediate state access +- Works across all platforms + +**Note:** The WebSocket connection is established lazily when the first operation (query, mutation, subscribe, action) is executed. + +**Optional: Auto-Connect on Startup** + +To establish the connection immediately when your app starts (recommended for better UX), trigger a lightweight query in your app's initialization. Using a dedicated health check query is the cleanest approach: + +**1. Create a health check query in your Convex backend (optional but recommended):** + +```typescript +// convex/health.ts +import { query } from "./_generated/server"; + +export const ping = query({ + args: {}, + handler: async () => { + return "ok"; + }, +}); +``` + +**2. Trigger it on app startup:** + +```dart +// In your home screen or app initialization +@override +void initState() { + super.initState(); + // Trigger connection immediately with health check + ConvexClient.instance.query('health:ping', {}); +} +``` + +**Alternative:** You can use any existing lightweight query instead of creating a dedicated health check: + +```dart +// Use any existing query to trigger connection +ConvexClient.instance.query('users:list', {'limit': '1'}); +``` + +### Manual Connection Check (Deprecated) + +For backward compatibility, you can check connection status manually using a health check query: + +```dart +// Configure a lightweight query for health checks +await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: 'https://my-app.convex.cloud', + healthCheckQuery: 'health:ping', // Lightweight health check query + ), +); + +// Check connection status (deprecated - use connectionState stream instead) +final status = await ConvexClient.instance.checkConnection(); + +switch (status) { + case ConnectionStatus.connected: + print('Connected!'); + case ConnectionStatus.timeout: + print('Connection timeout'); + case ConnectionStatus.error: + print('Connection error'); + case ConnectionStatus.unknown: + print('Not checked yet'); +} +``` + +### Manual Reconnect + +Trigger reconnection attempt manually: + +```dart +final connected = await ConvexClient.instance.reconnect(); +if (connected) { + print('Reconnected successfully'); +} +``` + +## Lifecycle Monitoring + +Monitor app lifecycle events to handle foreground/background transitions. + +### Listen to Lifecycle Events + +```dart +ConvexClient.instance.lifecycleEvents.listen((event) { + print('App lifecycle: $event'); + + if (event == AppLifecycleEvent.resumed) { + // App came to foreground + // Optionally reconnect or refresh data + ConvexClient.instance.reconnect(); + } + + if (event == AppLifecycleEvent.paused) { + // App went to background + // Optionally pause polling or save state + } +}); +``` + +### Lifecycle Events + +- `AppLifecycleEvent.resumed` - App in foreground +- `AppLifecycleEvent.paused` - App in background +- `AppLifecycleEvent.inactive` - App inactive (e.g., during phone call) +- `AppLifecycleEvent.detached` - App being terminated + +## API overview + +| Method | Description | +|--------|-------------| +| `ConvexClient.initialize(ConvexConfig)` | Initialize singleton client with configuration | +| `ConvexClient.instance` | Access singleton instance anywhere | +| `query(name, args)` | Execute a query with timeout, returns JSON string | +| `mutation({ name, args })` | Execute a mutation with timeout, returns JSON string | +| `action({ name, args })` | Execute an action with timeout, returns JSON string | +| `subscribe({ name, args, onUpdate, onError })` | Subscribe to real-time updates, returns `SubscriptionHandle` | +| `setAuth({ token })` | Set or clear static auth token | +| `setAuthWithRefresh({ fetchToken, onAuthChange })` | Set auth with automatic token refresh, returns `AuthHandleWrapper` | +| `authState` | Stream of auth state changes (`Stream`) | +| `isAuthenticated` | Current auth state (sync getter) | +| `clearAuth()` | Clear auth and stop token refresh | +| `connectionState` | Real-time WebSocket connection state stream (`Stream`) | +| `currentConnectionState` | Current connection state (sync getter) | +| `isConnected` | Returns true if WebSocket is connected (sync getter) | +| `checkConnection()` | _(Deprecated)_ Manually check connection status, returns `ConnectionStatus` | +| `reconnect()` | Manually trigger reconnection attempt, returns `bool` | +| `lifecycleEvents` | Stream of app lifecycle events (`Stream`) | +| `dispose()` | Clean up client resources | + +See the inline docs in `lib/src/convex_client.dart` for details. + +## Example app + +An example is provided under `example/`: + +``` +cd example +flutter run +``` + +The example demonstrates: +- Real-time chat with subscriptions +- Sending messages with mutations +- Authentication with JWT tokens +- Auth state management +- **WebSocket connection state monitoring** with visual indicators +- Lifecycle event monitoring (shows app state in AppBar) +- Connection screen with real-time state history +- Automatic connection on app startup +- Singleton pattern usage (`ConvexClient.instance`) + +## Troubleshooting + +### Build Issues + +- **Rust not found** (native platforms only): + - Visit [Rust Getting Started Guide](https://www.rust-lang.org/learn/get-started) + - Install Rust: + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` + - Update your PATH (add to `~/.bashrc`, `~/.zshrc`, or equivalent): + ```bash + source "$HOME/.cargo/env" + ``` + - Verify installation: + ```bash + rustc --version + cargo --version + ``` + - **Note**: Not needed for web platform +- **Android build issues**: Use JDK 11, ensure NDK is installed via Android SDK Manager +- **iOS/macOS**: Run `pod install` inside the `example/ios` or your app's `ios` folder if needed +- **Windows**: Install Visual Studio Build Tools with C++ workload + +### Connection Issues + +- **macOS stuck in "connecting" state**: Missing network entitlements - see [PLATFORM_CONFIGURATION.md](PLATFORM_CONFIGURATION.md#macos) +- **Android network errors**: Missing INTERNET permission - see [PLATFORM_CONFIGURATION.md](PLATFORM_CONFIGURATION.md#android) +- **WebSocket not connecting**: Check your `deploymentUrl` and network permissions +- **Timeout errors**: Increase `operationTimeout` in `ConvexConfig` + +**📖 For detailed troubleshooting, see [PLATFORM_CONFIGURATION.md](PLATFORM_CONFIGURATION.md#troubleshooting)** + +## Contributing + +Contributions are welcome! Please open an issue or pull request. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/third_party/convex_flutter/analysis_options.yaml b/third_party/convex_flutter/analysis_options.yaml new file mode 100644 index 00000000..a5744c1c --- /dev/null +++ b/third_party/convex_flutter/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/convex_flutter/android/build.gradle b/third_party/convex_flutter/android/build.gradle new file mode 100644 index 00000000..dfbbaf3b --- /dev/null +++ b/third_party/convex_flutter/android/build.gradle @@ -0,0 +1,56 @@ +// The Android Gradle Plugin builds the native code with the Android NDK. + +group 'com.flutter_rust_bridge.convex_flutter' +version '1.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + // The Android Gradle Plugin knows how to build native code with the NDK. + classpath 'com.android.tools.build:gradle:7.3.0' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.flutter_rust_bridge.convex_flutter' + } + + // Bumping the plugin compileSdkVersion requires all clients of this plugin + // to bump the version in their app. + compileSdkVersion 33 + + // Use the NDK version + // declared in /android/app/build.gradle file of the Flutter project. + // Replace it with a version number if this plugin requires a specfic NDK version. + // (e.g. ndkVersion "23.1.7779620") + ndkVersion android.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 19 + } +} + +apply from: "../cargokit/gradle/plugin.gradle" +cargokit { + manifestDir = "../rust" + libname = "convex_flutter" +} diff --git a/third_party/convex_flutter/android/settings.gradle b/third_party/convex_flutter/android/settings.gradle new file mode 100644 index 00000000..f6e5df69 --- /dev/null +++ b/third_party/convex_flutter/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'convex_flutter' diff --git a/third_party/convex_flutter/android/src/main/AndroidManifest.xml b/third_party/convex_flutter/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..43a45c47 --- /dev/null +++ b/third_party/convex_flutter/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/third_party/convex_flutter/build.yaml b/third_party/convex_flutter/build.yaml new file mode 100644 index 00000000..d82d0b6d --- /dev/null +++ b/third_party/convex_flutter/build.yaml @@ -0,0 +1,6 @@ +targets: + $default: + builders: + freezed: + generate_for: + - lib/src/rust/*.dart diff --git a/third_party/convex_flutter/cargokit/LICENSE b/third_party/convex_flutter/cargokit/LICENSE new file mode 100644 index 00000000..d33a5fea --- /dev/null +++ b/third_party/convex_flutter/cargokit/LICENSE @@ -0,0 +1,42 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Copyright 2022 Matej Knopp + +================================================================================ + +MIT LICENSE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS +OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + +APACHE LICENSE, VERSION 2.0 + +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. + diff --git a/third_party/convex_flutter/cargokit/README b/third_party/convex_flutter/cargokit/README new file mode 100644 index 00000000..398474db --- /dev/null +++ b/third_party/convex_flutter/cargokit/README @@ -0,0 +1,11 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +Experimental repository to provide glue for seamlessly integrating cargo build +with flutter plugins and packages. + +See https://matejknopp.com/post/flutter_plugin_in_rust_with_no_prebuilt_binaries/ +for a tutorial on how to use Cargokit. + +Example plugin available at https://github.com/irondash/hello_rust_ffi_plugin. + diff --git a/third_party/convex_flutter/cargokit/build_pod.sh b/third_party/convex_flutter/cargokit/build_pod.sh new file mode 100755 index 00000000..ed0e0d98 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_pod.sh @@ -0,0 +1,58 @@ +#!/bin/sh +set -e + +BASEDIR=$(dirname "$0") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +BASEDIR=$(cd "$BASEDIR" ; pwd -P) + +# Remove XCode SDK from path. Otherwise this breaks tool compilation when building iOS project +NEW_PATH=`echo $PATH | tr ":" "\n" | grep -v "Contents/Developer/" | tr "\n" ":"` + +export PATH=${NEW_PATH%?} # remove trailing : + +env + +# Platform name (macosx, iphoneos, iphonesimulator) +export CARGOKIT_DARWIN_PLATFORM_NAME=$PLATFORM_NAME + +# Arctive architectures (arm64, armv7, x86_64), space separated. +export CARGOKIT_DARWIN_ARCHS=$ARCHS + +# Current build configuration (Debug, Release) +export CARGOKIT_CONFIGURATION=$CONFIGURATION + +# Path to directory containing Cargo.toml. +export CARGOKIT_MANIFEST_DIR=$PODS_TARGET_SRCROOT/$1 + +# Temporary directory for build artifacts. +export CARGOKIT_TARGET_TEMP_DIR=$TARGET_TEMP_DIR + +# Output directory for final artifacts. +export CARGOKIT_OUTPUT_DIR=$PODS_CONFIGURATION_BUILD_DIR/$PRODUCT_NAME + +# Directory to store built tool artifacts. +export CARGOKIT_TOOL_TEMP_DIR=$TARGET_TEMP_DIR/build_tool + +# Directory inside root project. Not necessarily the top level directory of root project. +export CARGOKIT_ROOT_PROJECT_DIR=$SRCROOT + +FLUTTER_EXPORT_BUILD_ENVIRONMENT=( + "$PODS_ROOT/../Flutter/ephemeral/flutter_export_environment.sh" # macOS + "$PODS_ROOT/../Flutter/flutter_export_environment.sh" # iOS +) + +for path in "${FLUTTER_EXPORT_BUILD_ENVIRONMENT[@]}" +do + if [[ -f "$path" ]]; then + source "$path" + fi +done + +sh "$BASEDIR/run_build_tool.sh" build-pod "$@" + +# Make a symlink from built framework to phony file, which will be used as input to +# build script. This should force rebuild (podspec currently doesn't support alwaysOutOfDate +# attribute on custom build phase) +ln -fs "$OBJROOT/XCBuildData/build.db" "${BUILT_PRODUCTS_DIR}/cargokit_phony" +ln -fs "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_PATH}" "${BUILT_PRODUCTS_DIR}/cargokit_phony_out" diff --git a/third_party/convex_flutter/cargokit/build_tool/README.md b/third_party/convex_flutter/cargokit/build_tool/README.md new file mode 100644 index 00000000..a878c279 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/README.md @@ -0,0 +1,5 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/third_party/convex_flutter/cargokit/build_tool/analysis_options.yaml b/third_party/convex_flutter/cargokit/build_tool/analysis_options.yaml new file mode 100644 index 00000000..0e16a8b0 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/analysis_options.yaml @@ -0,0 +1,34 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - prefer_relative_imports + - directives_ordering + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/convex_flutter/cargokit/build_tool/bin/build_tool.dart b/third_party/convex_flutter/cargokit/build_tool/bin/build_tool.dart new file mode 100644 index 00000000..268eb524 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/bin/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:build_tool/build_tool.dart' as build_tool; + +void main(List arguments) { + build_tool.runMain(arguments); +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/build_tool.dart b/third_party/convex_flutter/cargokit/build_tool/lib/build_tool.dart new file mode 100644 index 00000000..7c1bb750 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/build_tool.dart @@ -0,0 +1,8 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'src/build_tool.dart' as build_tool; + +Future runMain(List args) async { + return build_tool.runMain(args); +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/android_environment.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/android_environment.dart new file mode 100644 index 00000000..15fc9eed --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/android_environment.dart @@ -0,0 +1,195 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; +import 'package:version/version.dart'; + +import 'target.dart'; +import 'util.dart'; + +class AndroidEnvironment { + AndroidEnvironment({ + required this.sdkPath, + required this.ndkVersion, + required this.minSdkVersion, + required this.targetTempDir, + required this.target, + }); + + static void clangLinkerWrapper(List args) { + final clang = Platform.environment['_CARGOKIT_NDK_LINK_CLANG']; + if (clang == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_CLANG env var"); + } + final target = Platform.environment['_CARGOKIT_NDK_LINK_TARGET']; + if (target == null) { + throw Exception( + "cargo-ndk rustc linker: didn't find _CARGOKIT_NDK_LINK_TARGET env var"); + } + + runCommand(clang, [ + target, + ...args, + ]); + } + + /// Full path to Android SDK. + final String sdkPath; + + /// Full version of Android NDK. + final String ndkVersion; + + /// Minimum supported SDK version. + final int minSdkVersion; + + /// Target directory for build artifacts. + final String targetTempDir; + + /// Target being built. + final Target target; + + bool ndkIsInstalled() { + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final ndkPackageXml = File(path.join(ndkPath, 'package.xml')); + return ndkPackageXml.existsSync(); + } + + void installNdk({ + required String javaHome, + }) { + final sdkManagerExtension = Platform.isWindows ? '.bat' : ''; + final sdkManager = path.join( + sdkPath, + 'cmdline-tools', + 'latest', + 'bin', + 'sdkmanager$sdkManagerExtension', + ); + + log.info('Installing NDK $ndkVersion'); + runCommand(sdkManager, [ + '--install', + 'ndk;$ndkVersion', + ], environment: { + 'JAVA_HOME': javaHome, + }); + } + + Future> buildEnvironment() async { + final hostArch = Platform.isMacOS + ? "darwin-x86_64" + : (Platform.isLinux ? "linux-x86_64" : "windows-x86_64"); + + final ndkPath = path.join(sdkPath, 'ndk', ndkVersion); + final toolchainPath = path.join( + ndkPath, + 'toolchains', + 'llvm', + 'prebuilt', + hostArch, + 'bin', + ); + + final minSdkVersion = + math.max(target.androidMinSdkVersion!, this.minSdkVersion); + + final exe = Platform.isWindows ? '.exe' : ''; + + final arKey = 'AR_${target.rust}'; + final arValue = ['${target.rust}-ar', 'llvm-ar', 'llvm-ar.exe'] + .map((e) => path.join(toolchainPath, e)) + .firstWhereOrNull((element) => File(element).existsSync()); + if (arValue == null) { + throw Exception('Failed to find ar for $target in $toolchainPath'); + } + + final targetArg = '--target=${target.rust}$minSdkVersion'; + + final ccKey = 'CC_${target.rust}'; + final ccValue = path.join(toolchainPath, 'clang$exe'); + final cfFlagsKey = 'CFLAGS_${target.rust}'; + final cFlagsValue = targetArg; + + final cxxKey = 'CXX_${target.rust}'; + final cxxValue = path.join(toolchainPath, 'clang++$exe'); + final cxxFlagsKey = 'CXXFLAGS_${target.rust}'; + final cxxFlagsValue = targetArg; + + final linkerKey = + 'cargo_target_${target.rust.replaceAll('-', '_')}_linker'.toUpperCase(); + + final ranlibKey = 'RANLIB_${target.rust}'; + final ranlibValue = path.join(toolchainPath, 'llvm-ranlib$exe'); + + final ndkVersionParsed = Version.parse(ndkVersion); + final rustFlagsKey = 'CARGO_ENCODED_RUSTFLAGS'; + final rustFlagsValue = _libGccWorkaround(targetTempDir, ndkVersionParsed); + + final runRustTool = + Platform.isWindows ? 'run_build_tool.cmd' : 'run_build_tool.sh'; + + final packagePath = (await Isolate.resolvePackageUri( + Uri.parse('package:build_tool/buildtool.dart')))! + .toFilePath(); + final selfPath = path.canonicalize(path.join( + packagePath, + '..', + '..', + '..', + runRustTool, + )); + + // Make sure that run_build_tool is working properly even initially launched directly + // through dart run. + final toolTempDir = + Platform.environment['CARGOKIT_TOOL_TEMP_DIR'] ?? targetTempDir; + + return { + arKey: arValue, + ccKey: ccValue, + cfFlagsKey: cFlagsValue, + cxxKey: cxxValue, + cxxFlagsKey: cxxFlagsValue, + ranlibKey: ranlibValue, + rustFlagsKey: rustFlagsValue, + linkerKey: selfPath, + // Recognized by main() so we know when we're acting as a wrapper + '_CARGOKIT_NDK_LINK_TARGET': targetArg, + '_CARGOKIT_NDK_LINK_CLANG': ccValue, + 'CARGOKIT_TOOL_TEMP_DIR': toolTempDir, + }; + } + + // Workaround for libgcc missing in NDK23, inspired by cargo-ndk + String _libGccWorkaround(String buildDir, Version ndkVersion) { + final workaroundDir = path.join( + buildDir, + 'cargokit', + 'libgcc_workaround', + '${ndkVersion.major}', + ); + Directory(workaroundDir).createSync(recursive: true); + if (ndkVersion.major >= 23) { + File(path.join(workaroundDir, 'libgcc.a')) + .writeAsStringSync('INPUT(-lunwind)'); + } else { + // Other way around, untested, forward libgcc.a from libunwind once Rust + // gets updated for NDK23+. + File(path.join(workaroundDir, 'libunwind.a')) + .writeAsStringSync('INPUT(-lgcc)'); + } + + var rustFlags = Platform.environment['CARGO_ENCODED_RUSTFLAGS'] ?? ''; + if (rustFlags.isNotEmpty) { + rustFlags = '$rustFlags\x1f'; + } + rustFlags = '$rustFlags-L\x1f$workaroundDir'; + return rustFlags; + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/artifacts_provider.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/artifacts_provider.dart new file mode 100644 index 00000000..e608cece --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/artifacts_provider.dart @@ -0,0 +1,266 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'builder.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'rustup.dart'; +import 'target.dart'; + +class Artifact { + /// File system location of the artifact. + final String path; + + /// Actual file name that the artifact should have in destination folder. + final String finalFileName; + + AritifactType get type { + if (finalFileName.endsWith('.dll') || + finalFileName.endsWith('.dll.lib') || + finalFileName.endsWith('.pdb') || + finalFileName.endsWith('.so') || + finalFileName.endsWith('.dylib')) { + return AritifactType.dylib; + } else if (finalFileName.endsWith('.lib') || finalFileName.endsWith('.a')) { + return AritifactType.staticlib; + } else { + throw Exception('Unknown artifact type for $finalFileName'); + } + } + + Artifact({ + required this.path, + required this.finalFileName, + }); +} + +final _log = Logger('artifacts_provider'); + +class ArtifactProvider { + ArtifactProvider({ + required this.environment, + required this.userOptions, + }); + + final BuildEnvironment environment; + final CargokitUserOptions userOptions; + + Future>> getArtifacts(List targets) async { + final result = await _getPrecompiledArtifacts(targets); + + final pendingTargets = List.of(targets); + pendingTargets.removeWhere((element) => result.containsKey(element)); + + if (pendingTargets.isEmpty) { + return result; + } + + final rustup = Rustup(); + for (final target in targets) { + final builder = RustBuilder(target: target, environment: environment); + builder.prepare(rustup); + _log.info('Building ${environment.crateInfo.packageName} for $target'); + final targetDir = await builder.build(); + // For local build accept both static and dynamic libraries. + final artifactNames = { + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.dylib, + remote: false, + ), + ...getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + aritifactType: AritifactType.staticlib, + remote: false, + ) + }; + final artifacts = artifactNames + .map((artifactName) => Artifact( + path: path.join(targetDir, artifactName), + finalFileName: artifactName, + )) + .where((element) => File(element.path).existsSync()) + .toList(); + result[target] = artifacts; + } + return result; + } + + Future>> _getPrecompiledArtifacts( + List targets) async { + if (userOptions.usePrecompiledBinaries == false) { + _log.info('Precompiled binaries are disabled'); + return {}; + } + if (environment.crateOptions.precompiledBinaries == null) { + _log.fine('Precompiled binaries not enabled for this crate'); + return {}; + } + + final start = Stopwatch()..start(); + final crateHash = CrateHash.compute(environment.manifestDir, + tempStorage: environment.targetTempDir); + _log.fine( + 'Computed crate hash $crateHash in ${start.elapsedMilliseconds}ms'); + + final downloadedArtifactsDir = + path.join(environment.targetTempDir, 'precompiled', crateHash); + Directory(downloadedArtifactsDir).createSync(recursive: true); + + final res = >{}; + + for (final target in targets) { + final requiredArtifacts = getArtifactNames( + target: target, + libraryName: environment.crateInfo.packageName, + remote: true, + ); + final artifactsForTarget = []; + + for (final artifact in requiredArtifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final downloadedPath = path.join(downloadedArtifactsDir, fileName); + if (!File(downloadedPath).existsSync()) { + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + await _tryDownloadArtifacts( + crateHash: crateHash, + fileName: fileName, + signatureFileName: signatureFileName, + finalPath: downloadedPath, + ); + } + if (File(downloadedPath).existsSync()) { + artifactsForTarget.add(Artifact( + path: downloadedPath, + finalFileName: artifact, + )); + } else { + break; + } + } + + // Only provide complete set of artifacts. + if (artifactsForTarget.length == requiredArtifacts.length) { + _log.fine('Found precompiled artifacts for $target'); + res[target] = artifactsForTarget; + } + } + + return res; + } + + static Future _get(Uri url, {Map? headers}) async { + int attempt = 0; + const maxAttempts = 10; + while (true) { + try { + return await get(url, headers: headers); + } on SocketException catch (e) { + // Try to detect reset by peer error and retry. + if (attempt++ < maxAttempts && + (e.osError?.errorCode == 54 || e.osError?.errorCode == 10054)) { + _log.severe( + 'Failed to download $url: $e, attempt $attempt of $maxAttempts, will retry...'); + await Future.delayed(Duration(seconds: 1)); + continue; + } else { + rethrow; + } + } + } + } + + Future _tryDownloadArtifacts({ + required String crateHash, + required String fileName, + required String signatureFileName, + required String finalPath, + }) async { + final precompiledBinaries = environment.crateOptions.precompiledBinaries!; + final prefix = precompiledBinaries.uriPrefix; + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = Uri.parse('$prefix$crateHash/$signatureFileName'); + _log.fine('Downloading signature from $signatureUrl'); + final signature = await _get(signatureUrl); + if (signature.statusCode == 404) { + _log.warning( + 'Precompiled binaries not available for crate hash $crateHash ($fileName)'); + return; + } + if (signature.statusCode != 200) { + _log.severe( + 'Failed to download signature $signatureUrl: status ${signature.statusCode}'); + return; + } + _log.fine('Downloading binary from $url'); + final res = await _get(url); + if (res.statusCode != 200) { + _log.severe('Failed to download binary $url: status ${res.statusCode}'); + return; + } + if (verify( + precompiledBinaries.publicKey, res.bodyBytes, signature.bodyBytes)) { + File(finalPath).writeAsBytesSync(res.bodyBytes); + } else { + _log.shout('Signature verification failed! Ignoring binary.'); + } + } +} + +enum AritifactType { + staticlib, + dylib, +} + +AritifactType artifactTypeForTarget(Target target) { + if (target.darwinPlatform != null) { + return AritifactType.staticlib; + } else { + return AritifactType.dylib; + } +} + +List getArtifactNames({ + required Target target, + required String libraryName, + required bool remote, + AritifactType? aritifactType, +}) { + aritifactType ??= artifactTypeForTarget(target); + if (target.darwinArch != null) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.dylib']; + } + } else if (target.rust.contains('-windows-')) { + if (aritifactType == AritifactType.staticlib) { + return ['$libraryName.lib']; + } else { + return [ + '$libraryName.dll', + '$libraryName.dll.lib', + if (!remote) '$libraryName.pdb' + ]; + } + } else if (target.rust.contains('-linux-')) { + if (aritifactType == AritifactType.staticlib) { + return ['lib$libraryName.a']; + } else { + return ['lib$libraryName.so']; + } + } else { + throw Exception("Unsupported target: ${target.rust}"); + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/build_cmake.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_cmake.dart new file mode 100644 index 00000000..6f3b2a4e --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_cmake.dart @@ -0,0 +1,40 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +class BuildCMake { + final CargokitUserOptions userOptions; + + BuildCMake({required this.userOptions}); + + Future build() async { + final targetPlatform = Environment.targetPlatform; + final target = Target.forFlutterName(Environment.targetPlatform); + if (target == null) { + throw Exception("Unknown target platform: $targetPlatform"); + } + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts([target]); + + final libs = artifacts[target]!; + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path) + .copySync(path.join(Environment.outputDir, lib.finalFileName)); + } + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/build_gradle.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_gradle.dart new file mode 100644 index 00000000..7e61fcbb --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_gradle.dart @@ -0,0 +1,49 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; + +final log = Logger('build_gradle'); + +class BuildGradle { + BuildGradle({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.targetPlatforms.map((arch) { + final target = Target.forFlutterName(arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: true); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + for (final target in targets) { + final libs = artifacts[target]!; + final outputDir = path.join(Environment.outputDir, target.android!); + Directory(outputDir).createSync(recursive: true); + + for (final lib in libs) { + if (lib.type == AritifactType.dylib) { + File(lib.path).copySync(path.join(outputDir, lib.finalFileName)); + } + } + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/build_pod.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_pod.dart new file mode 100644 index 00000000..8a9c0db5 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_pod.dart @@ -0,0 +1,89 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'target.dart'; +import 'util.dart'; + +class BuildPod { + BuildPod({required this.userOptions}); + + final CargokitUserOptions userOptions; + + Future build() async { + final targets = Environment.darwinArchs.map((arch) { + final target = Target.forDarwin( + platformName: Environment.darwinPlatformName, darwinAarch: arch); + if (target == null) { + throw Exception( + "Unknown darwin target or platform: $arch, ${Environment.darwinPlatformName}"); + } + return target; + }).toList(); + + final environment = BuildEnvironment.fromEnvironment(isAndroid: false); + final provider = + ArtifactProvider(environment: environment, userOptions: userOptions); + final artifacts = await provider.getArtifacts(targets); + + void performLipo(String targetFile, Iterable sourceFiles) { + runCommand("lipo", [ + '-create', + ...sourceFiles, + '-output', + targetFile, + ]); + } + + final outputDir = Environment.outputDir; + + Directory(outputDir).createSync(recursive: true); + + final staticLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.staticlib) + .toList(); + final dynamicLibs = artifacts.values + .expand((element) => element) + .where((element) => element.type == AritifactType.dylib) + .toList(); + + final libName = environment.crateInfo.packageName; + + // If there is static lib, use it and link it with pod + if (staticLibs.isNotEmpty) { + final finalTargetFile = path.join(outputDir, "lib$libName.a"); + performLipo(finalTargetFile, staticLibs.map((e) => e.path)); + } else { + // Otherwise try to replace bundle dylib with our dylib + final bundlePaths = [ + '$libName.framework/Versions/A/$libName', + '$libName.framework/$libName', + ]; + + for (final bundlePath in bundlePaths) { + final targetFile = path.join(outputDir, bundlePath); + if (File(targetFile).existsSync()) { + performLipo(targetFile, dynamicLibs.map((e) => e.path)); + + // Replace absolute id with @rpath one so that it works properly + // when moved to Frameworks. + runCommand("install_name_tool", [ + '-id', + '@rpath/$bundlePath', + targetFile, + ]); + return; + } + } + throw Exception('Unable to find bundle for dynamic library'); + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/build_tool.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_tool.dart new file mode 100644 index 00000000..c8f36981 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/build_tool.dart @@ -0,0 +1,271 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; + +import 'android_environment.dart'; +import 'build_cmake.dart'; +import 'build_gradle.dart'; +import 'build_pod.dart'; +import 'logging.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; +import 'util.dart'; +import 'verify_binaries.dart'; + +final log = Logger('build_tool'); + +abstract class BuildCommand extends Command { + Future runBuildCommand(CargokitUserOptions options); + + @override + Future run() async { + final options = CargokitUserOptions.load(); + + if (options.verboseLogging || + Platform.environment['CARGOKIT_VERBOSE'] == '1') { + enableVerboseLogging(); + } + + await runBuildCommand(options); + } +} + +class BuildPodCommand extends BuildCommand { + @override + final name = 'build-pod'; + + @override + final description = 'Build cocoa pod library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildPod(userOptions: options); + await build.build(); + } +} + +class BuildGradleCommand extends BuildCommand { + @override + final name = 'build-gradle'; + + @override + final description = 'Build android library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildGradle(userOptions: options); + await build.build(); + } +} + +class BuildCMakeCommand extends BuildCommand { + @override + final name = 'build-cmake'; + + @override + final description = 'Build CMake library'; + + @override + Future runBuildCommand(CargokitUserOptions options) async { + final build = BuildCMake(userOptions: options); + await build.build(); + } +} + +class GenKeyCommand extends Command { + @override + final name = 'gen-key'; + + @override + final description = 'Generate key pair for signing precompiled binaries'; + + @override + void run() { + final kp = generateKey(); + final private = HEX.encode(kp.privateKey.bytes); + final public = HEX.encode(kp.publicKey.bytes); + print("Private Key: $private"); + print("Public Key: $public"); + } +} + +class PrecompileBinariesCommand extends Command { + PrecompileBinariesCommand() { + argParser + ..addOption( + 'repository', + mandatory: true, + help: 'Github repository slug in format owner/name', + ) + ..addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ) + ..addMultiOption('target', + help: 'Rust target triple of artifact to build.\n' + 'Can be specified multiple times or omitted in which case\n' + 'all targets for current platform will be built.') + ..addOption( + 'android-sdk-location', + help: 'Location of Android SDK (if available)', + ) + ..addOption( + 'android-ndk-version', + help: 'Android NDK version (if available)', + ) + ..addOption( + 'android-min-sdk-version', + help: 'Android minimum rquired version (if available)', + ) + ..addOption( + 'temp-dir', + help: 'Directory to store temporary build artifacts', + ) + ..addFlag( + "verbose", + abbr: "v", + defaultsTo: false, + help: "Enable verbose logging", + ); + } + + @override + final name = 'precompile-binaries'; + + @override + final description = 'Prebuild and upload binaries\n' + 'Private key must be passed through PRIVATE_KEY environment variable. ' + 'Use gen_key through generate priave key.\n' + 'Github token must be passed as GITHUB_TOKEN environment variable.\n'; + + @override + Future run() async { + final verbose = argResults!['verbose'] as bool; + if (verbose) { + enableVerboseLogging(); + } + + final privateKeyString = Platform.environment['PRIVATE_KEY']; + if (privateKeyString == null) { + throw ArgumentError('Missing PRIVATE_KEY environment variable'); + } + final githubToken = Platform.environment['GITHUB_TOKEN']; + if (githubToken == null) { + throw ArgumentError('Missing GITHUB_TOKEN environment variable'); + } + final privateKey = HEX.decode(privateKeyString); + if (privateKey.length != 64) { + throw ArgumentError('Private key must be 64 bytes long'); + } + final manifestDir = argResults!['manifest-dir'] as String; + if (!Directory(manifestDir).existsSync()) { + throw ArgumentError('Manifest directory does not exist: $manifestDir'); + } + String? androidMinSdkVersionString = + argResults!['android-min-sdk-version'] as String?; + int? androidMinSdkVersion; + if (androidMinSdkVersionString != null) { + androidMinSdkVersion = int.tryParse(androidMinSdkVersionString); + if (androidMinSdkVersion == null) { + throw ArgumentError( + 'Invalid android-min-sdk-version: $androidMinSdkVersionString'); + } + } + final targetStrigns = argResults!['target'] as List; + final targets = targetStrigns.map((target) { + final res = Target.forRustTriple(target); + if (res == null) { + throw ArgumentError('Invalid target: $target'); + } + return res; + }).toList(growable: false); + final precompileBinaries = PrecompileBinaries( + privateKey: PrivateKey(privateKey), + githubToken: githubToken, + manifestDir: manifestDir, + repositorySlug: RepositorySlug.full(argResults!['repository'] as String), + targets: targets, + androidSdkLocation: argResults!['android-sdk-location'] as String?, + androidNdkVersion: argResults!['android-ndk-version'] as String?, + androidMinSdkVersion: androidMinSdkVersion, + tempDir: argResults!['temp-dir'] as String?, + ); + + await precompileBinaries.run(); + } +} + +class VerifyBinariesCommand extends Command { + VerifyBinariesCommand() { + argParser.addOption( + 'manifest-dir', + mandatory: true, + help: 'Directory containing Cargo.toml', + ); + } + + @override + final name = "verify-binaries"; + + @override + final description = 'Verifies published binaries\n' + 'Checks whether there is a binary published for each targets\n' + 'and checks the signature.'; + + @override + Future run() async { + final manifestDir = argResults!['manifest-dir'] as String; + final verifyBinaries = VerifyBinaries( + manifestDir: manifestDir, + ); + await verifyBinaries.run(); + } +} + +Future runMain(List args) async { + try { + // Init logging before options are loaded + initLogging(); + + if (Platform.environment['_CARGOKIT_NDK_LINK_TARGET'] != null) { + return AndroidEnvironment.clangLinkerWrapper(args); + } + + final runner = CommandRunner('build_tool', 'Cargokit built_tool') + ..addCommand(BuildPodCommand()) + ..addCommand(BuildGradleCommand()) + ..addCommand(BuildCMakeCommand()) + ..addCommand(GenKeyCommand()) + ..addCommand(PrecompileBinariesCommand()) + ..addCommand(VerifyBinariesCommand()); + + await runner.run(args); + } on ArgumentError catch (e) { + stderr.writeln(e.toString()); + exit(1); + } catch (e, s) { + log.severe(kDoubleSeparator); + log.severe('Cargokit BuildTool failed with error:'); + log.severe(kSeparator); + log.severe(e); + // This tells user to install Rust, there's no need to pollute the log with + // stack trace. + if (e is! RustupNotFoundException) { + log.severe(kSeparator); + log.severe(s); + log.severe(kSeparator); + log.severe('BuildTool arguments: $args'); + } + log.severe(kDoubleSeparator); + exit(1); + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/builder.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/builder.dart new file mode 100644 index 00000000..84c46e4f --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/builder.dart @@ -0,0 +1,198 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'android_environment.dart'; +import 'cargo.dart'; +import 'environment.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; +import 'util.dart'; + +final _log = Logger('builder'); + +enum BuildConfiguration { + debug, + release, + profile, +} + +extension on BuildConfiguration { + bool get isDebug => this == BuildConfiguration.debug; + String get rustName => switch (this) { + BuildConfiguration.debug => 'debug', + BuildConfiguration.release => 'release', + BuildConfiguration.profile => 'release', + }; +} + +class BuildException implements Exception { + final String message; + + BuildException(this.message); + + @override + String toString() { + return 'BuildException: $message'; + } +} + +class BuildEnvironment { + final BuildConfiguration configuration; + final CargokitCrateOptions crateOptions; + final String targetTempDir; + final String manifestDir; + final CrateInfo crateInfo; + + final bool isAndroid; + final String? androidSdkPath; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? javaHome; + + BuildEnvironment({ + required this.configuration, + required this.crateOptions, + required this.targetTempDir, + required this.manifestDir, + required this.crateInfo, + required this.isAndroid, + this.androidSdkPath, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.javaHome, + }); + + static BuildConfiguration parseBuildConfiguration(String value) { + // XCode configuration adds the flavor to configuration name. + final firstSegment = value.split('-').first; + final buildConfiguration = BuildConfiguration.values.firstWhereOrNull( + (e) => e.name == firstSegment, + ); + if (buildConfiguration == null) { + _log.warning('Unknown build configuraiton $value, will assume release'); + return BuildConfiguration.release; + } + return buildConfiguration; + } + + static BuildEnvironment fromEnvironment({ + required bool isAndroid, + }) { + final buildConfiguration = + parseBuildConfiguration(Environment.configuration); + final manifestDir = Environment.manifestDir; + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + final crateInfo = CrateInfo.load(manifestDir); + return BuildEnvironment( + configuration: buildConfiguration, + crateOptions: crateOptions, + targetTempDir: Environment.targetTempDir, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: isAndroid, + androidSdkPath: isAndroid ? Environment.sdkPath : null, + androidNdkVersion: isAndroid ? Environment.ndkVersion : null, + androidMinSdkVersion: + isAndroid ? int.parse(Environment.minSdkVersion) : null, + javaHome: isAndroid ? Environment.javaHome : null, + ); + } +} + +class RustBuilder { + final Target target; + final BuildEnvironment environment; + + RustBuilder({ + required this.target, + required this.environment, + }); + + void prepare( + Rustup rustup, + ) { + final toolchain = _toolchain; + if (rustup.installedTargets(toolchain) == null) { + rustup.installToolchain(toolchain); + } + if (toolchain == 'nightly') { + rustup.installRustSrcForNightly(); + } + if (!rustup.installedTargets(toolchain)!.contains(target.rust)) { + rustup.installTarget(target.rust, toolchain: toolchain); + } + } + + CargoBuildOptions? get _buildOptions => + environment.crateOptions.cargo[environment.configuration]; + + String get _toolchain => _buildOptions?.toolchain.name ?? 'stable'; + + /// Returns the path of directory containing build artifacts. + Future build() async { + final extraArgs = _buildOptions?.flags ?? []; + final manifestPath = path.join(environment.manifestDir, 'Cargo.toml'); + runCommand( + 'rustup', + [ + 'run', + _toolchain, + 'cargo', + 'build', + ...extraArgs, + '--manifest-path', + manifestPath, + '-p', + environment.crateInfo.packageName, + if (!environment.configuration.isDebug) '--release', + '--target', + target.rust, + '--target-dir', + environment.targetTempDir, + ], + environment: await _buildEnvironment(), + ); + return path.join( + environment.targetTempDir, + target.rust, + environment.configuration.rustName, + ); + } + + Future> _buildEnvironment() async { + if (target.android == null) { + return {}; + } else { + final sdkPath = environment.androidSdkPath; + final ndkVersion = environment.androidNdkVersion; + final minSdkVersion = environment.androidMinSdkVersion; + if (sdkPath == null) { + throw BuildException('androidSdkPath is not set'); + } + if (ndkVersion == null) { + throw BuildException('androidNdkVersion is not set'); + } + if (minSdkVersion == null) { + throw BuildException('androidMinSdkVersion is not set'); + } + final env = AndroidEnvironment( + sdkPath: sdkPath, + ndkVersion: ndkVersion, + minSdkVersion: minSdkVersion, + targetTempDir: environment.targetTempDir, + target: target, + ); + if (!env.ndkIsInstalled() && environment.javaHome != null) { + env.installNdk(javaHome: environment.javaHome!); + } + return env.buildEnvironment(); + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/cargo.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/cargo.dart new file mode 100644 index 00000000..0d8958ff --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/cargo.dart @@ -0,0 +1,48 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:toml/toml.dart'; + +class ManifestException { + ManifestException(this.message, {required this.fileName}); + + final String? fileName; + final String message; + + @override + String toString() { + if (fileName != null) { + return 'Failed to parse package manifest at $fileName: $message'; + } else { + return 'Failed to parse package manifest: $message'; + } + } +} + +class CrateInfo { + CrateInfo({required this.packageName}); + + final String packageName; + + static CrateInfo parseManifest(String manifest, {final String? fileName}) { + final toml = TomlDocument.parse(manifest); + final package = toml.toMap()['package']; + if (package == null) { + throw ManifestException('Missing package section', fileName: fileName); + } + final name = package['name']; + if (name == null) { + throw ManifestException('Missing package name', fileName: fileName); + } + return CrateInfo(packageName: name); + } + + static CrateInfo load(String manifestDir) { + final manifestFile = File(path.join(manifestDir, 'Cargo.toml')); + final manifest = manifestFile.readAsStringSync(); + return parseManifest(manifest, fileName: manifestFile.path); + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/crate_hash.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/crate_hash.dart new file mode 100644 index 00000000..0c4d88d1 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/crate_hash.dart @@ -0,0 +1,124 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as path; + +class CrateHash { + /// Computes a hash uniquely identifying crate content. This takes into account + /// content all all .rs files inside the src directory, as well as Cargo.toml, + /// Cargo.lock, build.rs and cargokit.yaml. + /// + /// If [tempStorage] is provided, computed hash is stored in a file in that directory + /// and reused on subsequent calls if the crate content hasn't changed. + static String compute(String manifestDir, {String? tempStorage}) { + return CrateHash._( + manifestDir: manifestDir, + tempStorage: tempStorage, + )._compute(); + } + + CrateHash._({ + required this.manifestDir, + required this.tempStorage, + }); + + String _compute() { + final files = getFiles(); + final tempStorage = this.tempStorage; + if (tempStorage != null) { + final quickHash = _computeQuickHash(files); + final quickHashFolder = Directory(path.join(tempStorage, 'crate_hash')); + quickHashFolder.createSync(recursive: true); + final quickHashFile = File(path.join(quickHashFolder.path, quickHash)); + if (quickHashFile.existsSync()) { + return quickHashFile.readAsStringSync(); + } + final hash = _computeHash(files); + quickHashFile.writeAsStringSync(hash); + return hash; + } else { + return _computeHash(files); + } + } + + /// Computes a quick hash based on files stat (without reading contents). This + /// is used to cache the real hash, which is slower to compute since it involves + /// reading every single file. + String _computeQuickHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + final data = ByteData(8); + for (final file in files) { + input.add(utf8.encode(file.path)); + final stat = file.statSync(); + data.setUint64(0, stat.size); + input.add(data.buffer.asUint8List()); + data.setUint64(0, stat.modified.millisecondsSinceEpoch); + input.add(data.buffer.asUint8List()); + } + + input.close(); + return base64Url.encode(output.events.single.bytes); + } + + String _computeHash(List files) { + final output = AccumulatorSink(); + final input = sha256.startChunkedConversion(output); + + void addTextFile(File file) { + // text Files are hashed by lines in case we're dealing with github checkout + // that auto-converts line endings. + final splitter = LineSplitter(); + if (file.existsSync()) { + final data = file.readAsStringSync(); + final lines = splitter.convert(data); + for (final line in lines) { + input.add(utf8.encode(line)); + } + } + } + + for (final file in files) { + addTextFile(file); + } + + input.close(); + final res = output.events.single; + + // Truncate to 128bits. + final hash = res.bytes.sublist(0, 16); + return hex.encode(hash); + } + + List getFiles() { + final src = Directory(path.join(manifestDir, 'src')); + final files = src + .listSync(recursive: true, followLinks: false) + .whereType() + .toList(); + files.sortBy((element) => element.path); + void addFile(String relative) { + final file = File(path.join(manifestDir, relative)); + if (file.existsSync()) { + files.add(file); + } + } + + addFile('Cargo.toml'); + addFile('Cargo.lock'); + addFile('build.rs'); + addFile('cargokit.yaml'); + return files; + } + + final String manifestDir; + final String? tempStorage; +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/environment.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/environment.dart new file mode 100644 index 00000000..996483a1 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/environment.dart @@ -0,0 +1,68 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +extension on String { + String resolveSymlink() => File(this).resolveSymbolicLinksSync(); +} + +class Environment { + /// Current build configuration (debug or release). + static String get configuration => + _getEnv("CARGOKIT_CONFIGURATION").toLowerCase(); + + static bool get isDebug => configuration == 'debug'; + static bool get isRelease => configuration == 'release'; + + /// Temporary directory where Rust build artifacts are placed. + static String get targetTempDir => _getEnv("CARGOKIT_TARGET_TEMP_DIR"); + + /// Final output directory where the build artifacts are placed. + static String get outputDir => _getEnvPath('CARGOKIT_OUTPUT_DIR'); + + /// Path to the crate manifest (containing Cargo.toml). + static String get manifestDir => _getEnvPath('CARGOKIT_MANIFEST_DIR'); + + /// Directory inside root project. Not necessarily root folder. Symlinks are + /// not resolved on purpose. + static String get rootProjectDir => _getEnv('CARGOKIT_ROOT_PROJECT_DIR'); + + // Pod + + /// Platform name (macosx, iphoneos, iphonesimulator). + static String get darwinPlatformName => + _getEnv("CARGOKIT_DARWIN_PLATFORM_NAME"); + + /// List of architectures to build for (arm64, armv7, x86_64). + static List get darwinArchs => + _getEnv("CARGOKIT_DARWIN_ARCHS").split(' '); + + // Gradle + static String get minSdkVersion => _getEnv("CARGOKIT_MIN_SDK_VERSION"); + static String get ndkVersion => _getEnv("CARGOKIT_NDK_VERSION"); + static String get sdkPath => _getEnvPath("CARGOKIT_SDK_DIR"); + static String get javaHome => _getEnvPath("CARGOKIT_JAVA_HOME"); + static List get targetPlatforms => + _getEnv("CARGOKIT_TARGET_PLATFORMS").split(','); + + // CMAKE + static String get targetPlatform => _getEnv("CARGOKIT_TARGET_PLATFORM"); + + static String _getEnv(String key) { + final res = Platform.environment[key]; + if (res == null) { + throw Exception("Missing environment variable $key"); + } + return res; + } + + static String _getEnvPath(String key) { + final res = _getEnv(key); + if (Directory(res).existsSync()) { + return res.resolveSymlink(); + } else { + return res; + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/logging.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/logging.dart new file mode 100644 index 00000000..5edd4fd1 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/logging.dart @@ -0,0 +1,52 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:logging/logging.dart'; + +const String kSeparator = "--"; +const String kDoubleSeparator = "=="; + +bool _lastMessageWasSeparator = false; + +void _log(LogRecord rec) { + final prefix = '${rec.level.name}: '; + final out = rec.level == Level.SEVERE ? stderr : stdout; + if (rec.message == kSeparator) { + if (!_lastMessageWasSeparator) { + out.write(prefix); + out.writeln('-' * 80); + _lastMessageWasSeparator = true; + } + return; + } else if (rec.message == kDoubleSeparator) { + out.write(prefix); + out.writeln('=' * 80); + _lastMessageWasSeparator = true; + return; + } + out.write(prefix); + out.writeln(rec.message); + _lastMessageWasSeparator = false; +} + +void initLogging() { + Logger.root.level = Level.INFO; + Logger.root.onRecord.listen((LogRecord rec) { + final lines = rec.message.split('\n'); + for (final line in lines) { + if (line.isNotEmpty || lines.length == 1 || line != lines.last) { + _log(LogRecord( + rec.level, + line, + rec.loggerName, + )); + } + } + }); +} + +void enableVerboseLogging() { + Logger.root.level = Level.ALL; +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/options.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/options.dart new file mode 100644 index 00000000..22aef1d3 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/options.dart @@ -0,0 +1,309 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:hex/hex.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +import 'builder.dart'; +import 'environment.dart'; +import 'rustup.dart'; + +final _log = Logger('options'); + +/// A class for exceptions that have source span information attached. +class SourceSpanException implements Exception { + // This is a getter so that subclasses can override it. + /// A message describing the exception. + String get message => _message; + final String _message; + + // This is a getter so that subclasses can override it. + /// The span associated with this exception. + /// + /// This may be `null` if the source location can't be determined. + SourceSpan? get span => _span; + final SourceSpan? _span; + + SourceSpanException(this._message, this._span); + + /// Returns a string representation of `this`. + /// + /// [color] may either be a [String], a [bool], or `null`. If it's a string, + /// it indicates an ANSI terminal color escape that should be used to + /// highlight the span's text. If it's `true`, it indicates that the text + /// should be highlighted using the default color. If it's `false` or `null`, + /// it indicates that the text shouldn't be highlighted. + @override + String toString({Object? color}) { + if (span == null) return message; + return 'Error on ${span!.message(message, color: color)}'; + } +} + +enum Toolchain { + stable, + beta, + nightly, +} + +class CargoBuildOptions { + final Toolchain toolchain; + final List flags; + + CargoBuildOptions({ + required this.toolchain, + required this.flags, + }); + + static Toolchain _toolchainFromNode(YamlNode node) { + if (node case YamlScalar(value: String name)) { + final toolchain = + Toolchain.values.firstWhereOrNull((element) => element.name == name); + if (toolchain != null) { + return toolchain; + } + } + throw SourceSpanException( + 'Unknown toolchain. Must be one of ${Toolchain.values.map((e) => e.name)}.', + node.span); + } + + static CargoBuildOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + Toolchain toolchain = Toolchain.stable; + List flags = []; + for (final MapEntry(:key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: 'toolchain')) { + toolchain = _toolchainFromNode(value); + } else if (key case YamlScalar(value: 'extra_flags')) { + if (value case YamlList(nodes: List list)) { + if (list.every((element) { + if (element case YamlScalar(value: String _)) { + return true; + } + return false; + })) { + flags = list.map((e) => e.value as String).toList(); + continue; + } + } + throw SourceSpanException( + 'Extra flags must be a list of strings', value.span); + } else { + throw SourceSpanException( + 'Unknown cargo option type. Must be "toolchain" or "extra_flags".', + key.span); + } + } + return CargoBuildOptions(toolchain: toolchain, flags: flags); + } +} + +extension on YamlMap { + /// Map that extracts keys so that we can do map case check on them. + Map get valueMap => + nodes.map((key, value) => MapEntry(key.value, value)); +} + +class PrecompiledBinaries { + final String uriPrefix; + final PublicKey publicKey; + + PrecompiledBinaries({ + required this.uriPrefix, + required this.publicKey, + }); + + static PublicKey _publicKeyFromHex(String key, SourceSpan? span) { + final bytes = HEX.decode(key); + if (bytes.length != 32) { + throw SourceSpanException( + 'Invalid public key. Must be 32 bytes long.', span); + } + return PublicKey(bytes); + } + + static PrecompiledBinaries parse(YamlNode node) { + if (node case YamlMap(valueMap: Map map)) { + if (map + case { + 'url_prefix': YamlNode urlPrefixNode, + 'public_key': YamlNode publicKeyNode, + }) { + final urlPrefix = switch (urlPrefixNode) { + YamlScalar(value: String urlPrefix) => urlPrefix, + _ => throw SourceSpanException( + 'Invalid URL prefix value.', urlPrefixNode.span), + }; + final publicKey = switch (publicKeyNode) { + YamlScalar(value: String publicKey) => + _publicKeyFromHex(publicKey, publicKeyNode.span), + _ => throw SourceSpanException( + 'Invalid public key value.', publicKeyNode.span), + }; + return PrecompiledBinaries( + uriPrefix: urlPrefix, + publicKey: publicKey, + ); + } + } + throw SourceSpanException( + 'Invalid precompiled binaries value. ' + 'Expected Map with "url_prefix" and "public_key".', + node.span); + } +} + +/// Cargokit options specified for Rust crate. +class CargokitCrateOptions { + CargokitCrateOptions({ + this.cargo = const {}, + this.precompiledBinaries, + }); + + final Map cargo; + final PrecompiledBinaries? precompiledBinaries; + + static CargokitCrateOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + final options = {}; + PrecompiledBinaries? precompiledBinaries; + + for (final entry in node.nodes.entries) { + if (entry + case MapEntry( + key: YamlScalar(value: 'cargo'), + value: YamlNode node, + )) { + if (node is! YamlMap) { + throw SourceSpanException('Cargo options must be a map', node.span); + } + for (final MapEntry(:YamlNode key, :value) in node.nodes.entries) { + if (key case YamlScalar(value: String name)) { + final configuration = BuildConfiguration.values + .firstWhereOrNull((element) => element.name == name); + if (configuration != null) { + options[configuration] = CargoBuildOptions.parse(value); + continue; + } + } + throw SourceSpanException( + 'Unknown build configuration. Must be one of ${BuildConfiguration.values.map((e) => e.name)}.', + key.span); + } + } else if (entry.key case YamlScalar(value: 'precompiled_binaries')) { + precompiledBinaries = PrecompiledBinaries.parse(entry.value); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "cargo" or "precompiled_binaries".', + entry.key.span); + } + } + return CargokitCrateOptions( + cargo: options, + precompiledBinaries: precompiledBinaries, + ); + } + + static CargokitCrateOptions load({ + required String manifestDir, + }) { + final uri = Uri.file(path.join(manifestDir, "cargokit.yaml")); + final file = File.fromUri(uri); + if (file.existsSync()) { + final contents = loadYamlNode(file.readAsStringSync(), sourceUrl: uri); + return parse(contents); + } else { + return CargokitCrateOptions(); + } + } +} + +class CargokitUserOptions { + // When Rustup is installed always build locally unless user opts into + // using precompiled binaries. + static bool defaultUsePrecompiledBinaries() { + return Rustup.executablePath() == null; + } + + CargokitUserOptions({ + required this.usePrecompiledBinaries, + required this.verboseLogging, + }); + + CargokitUserOptions._() + : usePrecompiledBinaries = defaultUsePrecompiledBinaries(), + verboseLogging = false; + + static CargokitUserOptions parse(YamlNode node) { + if (node is! YamlMap) { + throw SourceSpanException('Cargokit options must be a map', node.span); + } + bool usePrecompiledBinaries = defaultUsePrecompiledBinaries(); + bool verboseLogging = false; + + for (final entry in node.nodes.entries) { + if (entry.key case YamlScalar(value: 'use_precompiled_binaries')) { + if (entry.value case YamlScalar(value: bool value)) { + usePrecompiledBinaries = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "use_precompiled_binaries". Must be a boolean.', + entry.value.span); + } else if (entry.key case YamlScalar(value: 'verbose_logging')) { + if (entry.value case YamlScalar(value: bool value)) { + verboseLogging = value; + continue; + } + throw SourceSpanException( + 'Invalid value for "verbose_logging". Must be a boolean.', + entry.value.span); + } else { + throw SourceSpanException( + 'Unknown cargokit option type. Must be "use_precompiled_binaries" or "verbose_logging".', + entry.key.span); + } + } + return CargokitUserOptions( + usePrecompiledBinaries: usePrecompiledBinaries, + verboseLogging: verboseLogging, + ); + } + + static CargokitUserOptions load() { + String fileName = "cargokit_options.yaml"; + var userProjectDir = Directory(Environment.rootProjectDir); + + while (userProjectDir.parent.path != userProjectDir.path) { + final configFile = File(path.join(userProjectDir.path, fileName)); + if (configFile.existsSync()) { + final contents = loadYamlNode( + configFile.readAsStringSync(), + sourceUrl: configFile.uri, + ); + final res = parse(contents); + if (res.verboseLogging) { + _log.info('Found user options file at ${configFile.path}'); + } + return res; + } + userProjectDir = userProjectDir.parent; + } + return CargokitUserOptions._(); + } + + final bool usePrecompiledBinaries; + final bool verboseLogging; +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/precompile_binaries.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/precompile_binaries.dart new file mode 100644 index 00000000..c27f4195 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/precompile_binaries.dart @@ -0,0 +1,202 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts_provider.dart'; +import 'builder.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'rustup.dart'; +import 'target.dart'; + +final _log = Logger('precompile_binaries'); + +class PrecompileBinaries { + PrecompileBinaries({ + required this.privateKey, + required this.githubToken, + required this.repositorySlug, + required this.manifestDir, + required this.targets, + this.androidSdkLocation, + this.androidNdkVersion, + this.androidMinSdkVersion, + this.tempDir, + }); + + final PrivateKey privateKey; + final String githubToken; + final RepositorySlug repositorySlug; + final String manifestDir; + final List targets; + final String? androidSdkLocation; + final String? androidNdkVersion; + final int? androidMinSdkVersion; + final String? tempDir; + + static String fileName(Target target, String name) { + return '${target.rust}_$name'; + } + + static String signatureFileName(Target target, String name) { + return '${target.rust}_$name.sig'; + } + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final targets = List.of(this.targets); + if (targets.isEmpty) { + targets.addAll([ + ...Target.buildableTargets(), + if (androidSdkLocation != null) ...Target.androidTargets(), + ]); + } + + _log.info('Precompiling binaries for $targets'); + + final hash = CrateHash.compute(manifestDir); + _log.info('Computed crate hash: $hash'); + + final String tagName = 'precompiled_$hash'; + + final github = GitHub(auth: Authentication.withToken(githubToken)); + final repo = github.repositories; + final release = await _getOrCreateRelease( + repo: repo, + tagName: tagName, + packageName: crateInfo.packageName, + hash: hash, + ); + + final tempDir = this.tempDir != null + ? Directory(this.tempDir!) + : Directory.systemTemp.createTempSync('precompiled_'); + + tempDir.createSync(recursive: true); + + final crateOptions = CargokitCrateOptions.load( + manifestDir: manifestDir, + ); + + final buildEnvironment = BuildEnvironment( + configuration: BuildConfiguration.release, + crateOptions: crateOptions, + targetTempDir: tempDir.path, + manifestDir: manifestDir, + crateInfo: crateInfo, + isAndroid: androidSdkLocation != null, + androidSdkPath: androidSdkLocation, + androidNdkVersion: androidNdkVersion, + androidMinSdkVersion: androidMinSdkVersion, + ); + + final rustup = Rustup(); + + for (final target in targets) { + final artifactNames = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + if (artifactNames.every((name) { + final fileName = PrecompileBinaries.fileName(target, name); + return (release.assets ?? []).any((e) => e.name == fileName); + })) { + _log.info("All artifacts for $target already exist - skipping"); + continue; + } + + _log.info('Building for $target'); + + final builder = + RustBuilder(target: target, environment: buildEnvironment); + builder.prepare(rustup); + final res = await builder.build(); + + final assets = []; + for (final name in artifactNames) { + final file = File(path.join(res, name)); + if (!file.existsSync()) { + throw Exception('Missing artifact: ${file.path}'); + } + + final data = file.readAsBytesSync(); + final create = CreateReleaseAsset( + name: PrecompileBinaries.fileName(target, name), + contentType: "application/octet-stream", + assetData: data, + ); + final signature = sign(privateKey, data); + final signatureCreate = CreateReleaseAsset( + name: signatureFileName(target, name), + contentType: "application/octet-stream", + assetData: signature, + ); + bool verified = verify(public(privateKey), data, signature); + if (!verified) { + throw Exception('Signature verification failed'); + } + assets.add(create); + assets.add(signatureCreate); + } + _log.info('Uploading assets: ${assets.map((e) => e.name)}'); + for (final asset in assets) { + // This seems to be failing on CI so do it one by one + int retryCount = 0; + while (true) { + try { + await repo.uploadReleaseAssets(release, [asset]); + break; + } on Exception catch (e) { + if (retryCount == 10) { + rethrow; + } + ++retryCount; + _log.shout( + 'Upload failed (attempt $retryCount, will retry): ${e.toString()}'); + await Future.delayed(Duration(seconds: 2)); + } + } + } + } + + _log.info('Cleaning up'); + tempDir.deleteSync(recursive: true); + } + + Future _getOrCreateRelease({ + required RepositoriesService repo, + required String tagName, + required String packageName, + required String hash, + }) async { + Release release; + try { + _log.info('Fetching release $tagName'); + release = await repo.getReleaseByTagName(repositorySlug, tagName); + } on ReleaseNotFound { + _log.info('Release not found - creating release $tagName'); + release = await repo.createRelease( + repositorySlug, + CreateRelease.from( + tagName: tagName, + name: 'Precompiled binaries ${hash.substring(0, 8)}', + targetCommitish: null, + isDraft: false, + isPrerelease: false, + body: 'Precompiled binaries for crate $packageName, ' + 'crate hash $hash.', + )); + } + return release; + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/rustup.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/rustup.dart new file mode 100644 index 00000000..0ac8d086 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/rustup.dart @@ -0,0 +1,136 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:path/path.dart' as path; + +import 'util.dart'; + +class _Toolchain { + _Toolchain( + this.name, + this.targets, + ); + + final String name; + final List targets; +} + +class Rustup { + List? installedTargets(String toolchain) { + final targets = _installedTargets(toolchain); + return targets != null ? List.unmodifiable(targets) : null; + } + + void installToolchain(String toolchain) { + log.info("Installing Rust toolchain: $toolchain"); + runCommand("rustup", ['toolchain', 'install', toolchain]); + _installedToolchains + .add(_Toolchain(toolchain, _getInstalledTargets(toolchain))); + } + + void installTarget( + String target, { + required String toolchain, + }) { + log.info("Installing Rust target: $target"); + runCommand("rustup", [ + 'target', + 'add', + '--toolchain', + toolchain, + target, + ]); + _installedTargets(toolchain)?.add(target); + } + + final List<_Toolchain> _installedToolchains; + + Rustup() : _installedToolchains = _getInstalledToolchains(); + + List? _installedTargets(String toolchain) => _installedToolchains + .firstWhereOrNull( + (e) => e.name == toolchain || e.name.startsWith('$toolchain-')) + ?.targets; + + static List<_Toolchain> _getInstalledToolchains() { + String extractToolchainName(String line) { + // ignore (default) after toolchain name + final parts = line.split(' '); + return parts[0]; + } + + final res = runCommand("rustup", ['toolchain', 'list']); + + // To list all non-custom toolchains, we need to filter out lines that + // don't start with "stable", "beta", or "nightly". + Pattern nonCustom = RegExp(r"^(stable|beta|nightly)"); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty && e.startsWith(nonCustom)) + .map(extractToolchainName) + .toList(growable: true); + + return lines + .map( + (name) => _Toolchain( + name, + _getInstalledTargets(name), + ), + ) + .toList(growable: true); + } + + static List _getInstalledTargets(String toolchain) { + final res = runCommand("rustup", [ + 'target', + 'list', + '--toolchain', + toolchain, + '--installed', + ]); + final lines = res.stdout + .toString() + .split('\n') + .where((e) => e.isNotEmpty) + .toList(growable: true); + return lines; + } + + bool _didInstallRustSrcForNightly = false; + + void installRustSrcForNightly() { + if (_didInstallRustSrcForNightly) { + return; + } + // Useful for -Z build-std + runCommand( + "rustup", + ['component', 'add', 'rust-src', '--toolchain', 'nightly'], + ); + _didInstallRustSrcForNightly = true; + } + + static String? executablePath() { + final envPath = Platform.environment['PATH']; + final envPathSeparator = Platform.isWindows ? ';' : ':'; + final home = Platform.isWindows + ? Platform.environment['USERPROFILE'] + : Platform.environment['HOME']; + final paths = [ + if (home != null) path.join(home, '.cargo', 'bin'), + if (envPath != null) ...envPath.split(envPathSeparator), + ]; + for (final p in paths) { + final rustup = Platform.isWindows ? 'rustup.exe' : 'rustup'; + final rustupPath = path.join(p, rustup); + if (File(rustupPath).existsSync()) { + return rustupPath; + } + } + return null; + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/target.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/target.dart new file mode 100644 index 00000000..6fbc58b6 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/target.dart @@ -0,0 +1,140 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:collection/collection.dart'; + +import 'util.dart'; + +class Target { + Target({ + required this.rust, + this.flutter, + this.android, + this.androidMinSdkVersion, + this.darwinPlatform, + this.darwinArch, + }); + + static final all = [ + Target( + rust: 'armv7-linux-androideabi', + flutter: 'android-arm', + android: 'armeabi-v7a', + androidMinSdkVersion: 16, + ), + Target( + rust: 'aarch64-linux-android', + flutter: 'android-arm64', + android: 'arm64-v8a', + androidMinSdkVersion: 21, + ), + Target( + rust: 'i686-linux-android', + flutter: 'android-x86', + android: 'x86', + androidMinSdkVersion: 16, + ), + Target( + rust: 'x86_64-linux-android', + flutter: 'android-x64', + android: 'x86_64', + androidMinSdkVersion: 21, + ), + Target( + rust: 'x86_64-pc-windows-msvc', + flutter: 'windows-x64', + ), + Target( + rust: 'x86_64-unknown-linux-gnu', + flutter: 'linux-x64', + ), + Target( + rust: 'aarch64-unknown-linux-gnu', + flutter: 'linux-arm64', + ), + Target( + rust: 'x86_64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'x86_64', + ), + Target( + rust: 'aarch64-apple-darwin', + darwinPlatform: 'macosx', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios', + darwinPlatform: 'iphoneos', + darwinArch: 'arm64', + ), + Target( + rust: 'aarch64-apple-ios-sim', + darwinPlatform: 'iphonesimulator', + darwinArch: 'arm64', + ), + Target( + rust: 'x86_64-apple-ios', + darwinPlatform: 'iphonesimulator', + darwinArch: 'x86_64', + ), + ]; + + static Target? forFlutterName(String flutterName) { + return all.firstWhereOrNull((element) => element.flutter == flutterName); + } + + static Target? forDarwin({ + required String platformName, + required String darwinAarch, + }) { + return all.firstWhereOrNull((element) => // + element.darwinPlatform == platformName && + element.darwinArch == darwinAarch); + } + + static Target? forRustTriple(String triple) { + return all.firstWhereOrNull((element) => element.rust == triple); + } + + static List androidTargets() { + return all + .where((element) => element.android != null) + .toList(growable: false); + } + + /// Returns buildable targets on current host platform ignoring Android targets. + static List buildableTargets() { + if (Platform.isLinux) { + // Right now we don't support cross-compiling on Linux. So we just return + // the host target. + final arch = runCommand('arch', []).stdout as String; + if (arch.trim() == 'aarch64') { + return [Target.forRustTriple('aarch64-unknown-linux-gnu')!]; + } else { + return [Target.forRustTriple('x86_64-unknown-linux-gnu')!]; + } + } + return all.where((target) { + if (Platform.isWindows) { + return target.rust.contains('-windows-'); + } else if (Platform.isMacOS) { + return target.darwinPlatform != null; + } + return false; + }).toList(growable: false); + } + + @override + String toString() { + return rust; + } + + final String? flutter; + final String rust; + final String? android; + final int? androidMinSdkVersion; + final String? darwinPlatform; + final String? darwinArch; +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/util.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/util.dart new file mode 100644 index 00000000..8bb6a872 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/util.dart @@ -0,0 +1,172 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'logging.dart'; +import 'rustup.dart'; + +final log = Logger("process"); + +class CommandFailedException implements Exception { + final String executable; + final List arguments; + final ProcessResult result; + + CommandFailedException({ + required this.executable, + required this.arguments, + required this.result, + }); + + @override + String toString() { + final stdout = result.stdout.toString().trim(); + final stderr = result.stderr.toString().trim(); + return [ + "External Command: $executable ${arguments.map((e) => '"$e"').join(' ')}", + "Returned Exit Code: ${result.exitCode}", + kSeparator, + "STDOUT:", + if (stdout.isNotEmpty) stdout, + kSeparator, + "STDERR:", + if (stderr.isNotEmpty) stderr, + ].join('\n'); + } +} + +class TestRunCommandArgs { + final String executable; + final List arguments; + final String? workingDirectory; + final Map? environment; + final bool includeParentEnvironment; + final bool runInShell; + final Encoding? stdoutEncoding; + final Encoding? stderrEncoding; + + TestRunCommandArgs({ + required this.executable, + required this.arguments, + this.workingDirectory, + this.environment, + this.includeParentEnvironment = true, + this.runInShell = false, + this.stdoutEncoding, + this.stderrEncoding, + }); +} + +class TestRunCommandResult { + TestRunCommandResult({ + this.pid = 1, + this.exitCode = 0, + this.stdout = '', + this.stderr = '', + }); + + final int pid; + final int exitCode; + final String stdout; + final String stderr; +} + +TestRunCommandResult Function(TestRunCommandArgs args)? testRunCommandOverride; + +ProcessResult runCommand( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, +}) { + if (testRunCommandOverride != null) { + final result = testRunCommandOverride!(TestRunCommandArgs( + executable: executable, + arguments: arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + )); + return ProcessResult( + result.pid, + result.exitCode, + result.stdout, + result.stderr, + ); + } + log.finer('Running command $executable ${arguments.join(' ')}'); + final res = Process.runSync( + _resolveExecutable(executable), + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stderrEncoding: stderrEncoding, + stdoutEncoding: stdoutEncoding, + ); + if (res.exitCode != 0) { + throw CommandFailedException( + executable: executable, + arguments: arguments, + result: res, + ); + } else { + return res; + } +} + +class RustupNotFoundException implements Exception { + @override + String toString() { + return [ + ' ', + 'rustup not found in PATH.', + ' ', + 'Maybe you need to install Rust? It only takes a minute:', + ' ', + if (Platform.isWindows) 'https://www.rust-lang.org/tools/install', + if (hasHomebrewRustInPath()) ...[ + '\$ brew unlink rust # Unlink homebrew Rust from PATH', + ], + if (!Platform.isWindows) + "\$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh", + ' ', + ].join('\n'); + } + + static bool hasHomebrewRustInPath() { + if (!Platform.isMacOS) { + return false; + } + final envPath = Platform.environment['PATH'] ?? ''; + final paths = envPath.split(':'); + return paths.any((p) { + return p.contains('homebrew') && File(path.join(p, 'rustc')).existsSync(); + }); + } +} + +String _resolveExecutable(String executable) { + if (executable == 'rustup') { + final resolved = Rustup.executablePath(); + if (resolved != null) { + return resolved; + } + throw RustupNotFoundException(); + } else { + return executable; + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/lib/src/verify_binaries.dart b/third_party/convex_flutter/cargokit/build_tool/lib/src/verify_binaries.dart new file mode 100644 index 00000000..2366b57b --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/lib/src/verify_binaries.dart @@ -0,0 +1,84 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import 'dart:io'; + +import 'package:ed25519_edwards/ed25519_edwards.dart'; +import 'package:http/http.dart'; + +import 'artifacts_provider.dart'; +import 'cargo.dart'; +import 'crate_hash.dart'; +import 'options.dart'; +import 'precompile_binaries.dart'; +import 'target.dart'; + +class VerifyBinaries { + VerifyBinaries({ + required this.manifestDir, + }); + + final String manifestDir; + + Future run() async { + final crateInfo = CrateInfo.load(manifestDir); + + final config = CargokitCrateOptions.load(manifestDir: manifestDir); + final precompiledBinaries = config.precompiledBinaries; + if (precompiledBinaries == null) { + stdout.writeln('Crate does not support precompiled binaries.'); + } else { + final crateHash = CrateHash.compute(manifestDir); + stdout.writeln('Crate hash: $crateHash'); + + for (final target in Target.all) { + final message = 'Checking ${target.rust}...'; + stdout.write(message.padRight(40)); + stdout.flush(); + + final artifacts = getArtifactNames( + target: target, + libraryName: crateInfo.packageName, + remote: true, + ); + + final prefix = precompiledBinaries.uriPrefix; + + bool ok = true; + + for (final artifact in artifacts) { + final fileName = PrecompileBinaries.fileName(target, artifact); + final signatureFileName = + PrecompileBinaries.signatureFileName(target, artifact); + + final url = Uri.parse('$prefix$crateHash/$fileName'); + final signatureUrl = + Uri.parse('$prefix$crateHash/$signatureFileName'); + + final signature = await get(signatureUrl); + if (signature.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + final asset = await get(url); + if (asset.statusCode != 200) { + stdout.writeln('MISSING'); + ok = false; + break; + } + + if (!verify(precompiledBinaries.publicKey, asset.bodyBytes, + signature.bodyBytes)) { + stdout.writeln('INVALID SIGNATURE'); + ok = false; + } + } + + if (ok) { + stdout.writeln('OK'); + } + } + } + } +} diff --git a/third_party/convex_flutter/cargokit/build_tool/pubspec.lock b/third_party/convex_flutter/cargokit/build_tool/pubspec.lock new file mode 100644 index 00000000..343bdd36 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/pubspec.lock @@ -0,0 +1,453 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + url: "https://pub.dev" + source: hosted + version: "64.0.0" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + url: "https://pub.dev" + source: hosted + version: "6.2.0" + args: + dependency: "direct main" + description: + name: args + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: "direct main" + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: "direct main" + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "2fb815080e44a09b85e0f2ca8a820b15053982b2e714b59267719e8a9ff17097" + url: "https://pub.dev" + source: hosted + version: "1.6.3" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + ed25519_edwards: + dependency: "direct main" + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + file: + dependency: transitive + description: + name: file + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" + source: hosted + version: "6.1.4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + github: + dependency: "direct main" + description: + name: github + sha256: "9966bc13bf612342e916b0a343e95e5f046c88f602a14476440e9b75d2295411" + url: "https://pub.dev" + source: hosted + version: "9.17.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: "direct main" + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "759d1a329847dd0f39226c688d3e06a6b8679668e350e2891a6474f8b4bb8525" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: b10a7b2ff83d83c777edba3c6a0f97045ddadd56c944e1a23a3fdf43a1bf4467 + url: "https://pub.dev" + source: hosted + version: "4.8.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: "direct main" + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.dev" + source: hosted + version: "0.12.16" + meta: + dependency: transitive + description: + name: meta + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + mime: + dependency: transitive + description: + name: mime + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + url: "https://pub.dev" + source: hosted + version: "1.0.4" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "2ad4cddff7f5cc0e2d13069f2a3f7a73ca18f66abd6f5ecf215219cdb3638edb" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: cb3798bef7fc021ac45b308f4b51208a152792445cce0448c9a4ba5879dd8750 + url: "https://pub.dev" + source: hosted + version: "5.4.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: "direct main" + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "9b0dd8e36af4a5b1569029949d50a52cb2a2a2fdaa20cebb96e6603b9ae241f9" + url: "https://pub.dev" + source: hosted + version: "1.24.6" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.dev" + source: hosted + version: "0.6.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4bef837e56375537055fdbbbf6dd458b1859881f4c7e6da936158f77d61ab265" + url: "https://pub.dev" + source: hosted + version: "0.5.6" + toml: + dependency: "direct main" + description: + name: toml + sha256: "157c5dca5160fced243f3ce984117f729c788bb5e475504f3dbcda881accee44" + url: "https://pub.dev" + source: hosted + version: "0.14.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + version: + dependency: "direct main" + description: + name: version + sha256: "2307e23a45b43f96469eeab946208ed63293e8afca9c28cd8b5241ff31c55f55" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0fae432c85c4ea880b33b497d32824b97795b04cdaa74d270219572a1f50268d" + url: "https://pub.dev" + source: hosted + version: "11.9.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + url: "https://pub.dev" + source: hosted + version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: "direct main" + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.0.0 <4.0.0" diff --git a/third_party/convex_flutter/cargokit/build_tool/pubspec.yaml b/third_party/convex_flutter/cargokit/build_tool/pubspec.yaml new file mode 100644 index 00000000..18c61e33 --- /dev/null +++ b/third_party/convex_flutter/cargokit/build_tool/pubspec.yaml @@ -0,0 +1,33 @@ +# This is copied from Cargokit (which is the official way to use it currently) +# Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +name: build_tool +description: Cargokit build_tool. Facilitates the build of Rust crate during Flutter application build. +publish_to: none +version: 1.0.0 + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # these are pinned on purpose because the bundle_tool_runner doesn't have + # pubspec.lock. See run_build_tool.sh + logging: 1.2.0 + path: 1.8.0 + version: 3.0.0 + collection: 1.18.0 + ed25519_edwards: 0.3.1 + hex: 0.2.0 + yaml: 3.1.2 + source_span: 1.10.0 + github: 9.17.0 + args: 2.4.2 + crypto: 3.0.3 + convert: 3.1.1 + http: 1.1.0 + toml: 0.14.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/third_party/convex_flutter/cargokit/cmake/cargokit.cmake b/third_party/convex_flutter/cargokit/cmake/cargokit.cmake new file mode 100644 index 00000000..ddd05df9 --- /dev/null +++ b/third_party/convex_flutter/cargokit/cmake/cargokit.cmake @@ -0,0 +1,99 @@ +SET(cargokit_cmake_root "${CMAKE_CURRENT_LIST_DIR}/..") + +# Workaround for https://github.com/dart-lang/pub/issues/4010 +get_filename_component(cargokit_cmake_root "${cargokit_cmake_root}" REALPATH) + +if(WIN32) + # REALPATH does not properly resolve symlinks on windows :-/ + execute_process(COMMAND powershell -ExecutionPolicy Bypass -File "${CMAKE_CURRENT_LIST_DIR}/resolve_symlinks.ps1" "${cargokit_cmake_root}" OUTPUT_VARIABLE cargokit_cmake_root OUTPUT_STRIP_TRAILING_WHITESPACE) +endif() + +# Arguments +# - target: CMAKE target to which rust library is linked +# - manifest_dir: relative path from current folder to directory containing cargo manifest +# - lib_name: cargo package name +# - any_symbol_name: name of any exported symbol from the library. +# used on windows to force linking with library. +function(apply_cargokit target manifest_dir lib_name any_symbol_name) + + set(CARGOKIT_LIB_NAME "${lib_name}") + set(CARGOKIT_LIB_FULL_NAME "${CMAKE_SHARED_MODULE_PREFIX}${CARGOKIT_LIB_NAME}${CMAKE_SHARED_MODULE_SUFFIX}") + if (CMAKE_CONFIGURATION_TYPES) + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}/$") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/$/${CARGOKIT_LIB_FULL_NAME}") + else() + set(CARGOKIT_OUTPUT_DIR "${CMAKE_CURRENT_BINARY_DIR}") + set(OUTPUT_LIB "${CMAKE_CURRENT_BINARY_DIR}/${CARGOKIT_LIB_FULL_NAME}") + endif() + set(CARGOKIT_TEMP_DIR "${CMAKE_CURRENT_BINARY_DIR}/cargokit_build") + + if (FLUTTER_TARGET_PLATFORM) + set(CARGOKIT_TARGET_PLATFORM "${FLUTTER_TARGET_PLATFORM}") + else() + set(CARGOKIT_TARGET_PLATFORM "windows-x64") + endif() + + set(CARGOKIT_ENV + "CARGOKIT_CMAKE=${CMAKE_COMMAND}" + "CARGOKIT_CONFIGURATION=$" + "CARGOKIT_MANIFEST_DIR=${CMAKE_CURRENT_SOURCE_DIR}/${manifest_dir}" + "CARGOKIT_TARGET_TEMP_DIR=${CARGOKIT_TEMP_DIR}" + "CARGOKIT_OUTPUT_DIR=${CARGOKIT_OUTPUT_DIR}" + "CARGOKIT_TARGET_PLATFORM=${CARGOKIT_TARGET_PLATFORM}" + "CARGOKIT_TOOL_TEMP_DIR=${CARGOKIT_TEMP_DIR}/tool" + "CARGOKIT_ROOT_PROJECT_DIR=${CMAKE_SOURCE_DIR}" + ) + + if (WIN32) + set(SCRIPT_EXTENSION ".cmd") + set(IMPORT_LIB_EXTENSION ".lib") + else() + set(SCRIPT_EXTENSION ".sh") + set(IMPORT_LIB_EXTENSION "") + execute_process(COMMAND chmod +x "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}") + endif() + + # Using generators in custom command is only supported in CMake 3.20+ + if (CMAKE_CONFIGURATION_TYPES AND ${CMAKE_VERSION} VERSION_LESS "3.20.0") + foreach(CONFIG IN LISTS CMAKE_CONFIGURATION_TYPES) + add_custom_command( + OUTPUT + "${CMAKE_CURRENT_BINARY_DIR}/${CONFIG}/${CARGOKIT_LIB_FULL_NAME}" + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endforeach() + else() + add_custom_command( + OUTPUT + ${OUTPUT_LIB} + "${CMAKE_CURRENT_BINARY_DIR}/_phony_" + COMMAND ${CMAKE_COMMAND} -E env ${CARGOKIT_ENV} + "${cargokit_cmake_root}/run_build_tool${SCRIPT_EXTENSION}" build-cmake + VERBATIM + ) + endif() + + + set_source_files_properties("${CMAKE_CURRENT_BINARY_DIR}/_phony_" PROPERTIES SYMBOLIC TRUE) + + if (TARGET ${target}) + # If we have actual cmake target provided create target and make existing + # target depend on it + add_custom_target("${target}_cargokit" DEPENDS ${OUTPUT_LIB}) + add_dependencies("${target}" "${target}_cargokit") + target_link_libraries("${target}" PRIVATE "${OUTPUT_LIB}${IMPORT_LIB_EXTENSION}") + if(WIN32) + target_link_options(${target} PRIVATE "/INCLUDE:${any_symbol_name}") + endif() + else() + # Otherwise (FFI) just use ALL to force building always + add_custom_target("${target}_cargokit" ALL DEPENDS ${OUTPUT_LIB}) + endif() + + # Allow adding the output library to plugin bundled libraries + set("${target}_cargokit_lib" ${OUTPUT_LIB} PARENT_SCOPE) + +endfunction() diff --git a/third_party/convex_flutter/cargokit/cmake/resolve_symlinks.ps1 b/third_party/convex_flutter/cargokit/cmake/resolve_symlinks.ps1 new file mode 100644 index 00000000..2ac593a1 --- /dev/null +++ b/third_party/convex_flutter/cargokit/cmake/resolve_symlinks.ps1 @@ -0,0 +1,34 @@ +function Resolve-Symlinks { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [string] $Path + ) + + [string] $separator = '/' + [string[]] $parts = $Path.Split($separator) + + [string] $realPath = '' + foreach ($part in $parts) { + if ($realPath -and !$realPath.EndsWith($separator)) { + $realPath += $separator + } + + $realPath += $part.Replace('\', '/') + + # The slash is important when using Get-Item on Drive letters in pwsh. + if (-not($realPath.Contains($separator)) -and $realPath.EndsWith(':')) { + $realPath += '/' + } + + $item = Get-Item $realPath + if ($item.LinkTarget) { + $realPath = $item.LinkTarget.Replace('\', '/') + } + } + $realPath +} + +$path = Resolve-Symlinks -Path $args[0] +Write-Host $path diff --git a/third_party/convex_flutter/cargokit/gradle/plugin.gradle b/third_party/convex_flutter/cargokit/gradle/plugin.gradle new file mode 100644 index 00000000..4af35ee0 --- /dev/null +++ b/third_party/convex_flutter/cargokit/gradle/plugin.gradle @@ -0,0 +1,179 @@ +/// This is copied from Cargokit (which is the official way to use it currently) +/// Details: https://fzyzcjy.github.io/flutter_rust_bridge/manual/integrate/builtin + +import java.nio.file.Paths +import org.apache.tools.ant.taskdefs.condition.Os + +CargoKitPlugin.file = buildscript.sourceFile + +apply plugin: CargoKitPlugin + +class CargoKitExtension { + String manifestDir; // Relative path to folder containing Cargo.toml + String libname; // Library name within Cargo.toml. Must be a cdylib +} + +abstract class CargoKitBuildTask extends DefaultTask { + + @Input + String buildMode + + @Input + String buildDir + + @Input + String outputDir + + @Input + String ndkVersion + + @Input + String sdkDirectory + + @Input + int compileSdkVersion; + + @Input + int minSdkVersion; + + @Input + String pluginFile + + @Input + List targetPlatforms + + @TaskAction + def build() { + if (project.cargokit.manifestDir == null) { + throw new GradleException("Property 'manifestDir' must be set on cargokit extension"); + } + + if (project.cargokit.libname == null) { + throw new GradleException("Property 'libname' must be set on cargokit extension"); + } + + def executableName = Os.isFamily(Os.FAMILY_WINDOWS) ? "run_build_tool.cmd" : "run_build_tool.sh" + def path = Paths.get(new File(pluginFile).parent, "..", executableName); + + def manifestDir = Paths.get(project.buildscript.sourceFile.parent, project.cargokit.manifestDir) + + def rootProjectDir = project.rootProject.projectDir + + if (!Os.isFamily(Os.FAMILY_WINDOWS)) { + project.exec { + commandLine 'chmod', '+x', path + } + } + + project.exec { + executable path + args "build-gradle" + environment "CARGOKIT_ROOT_PROJECT_DIR", rootProjectDir + environment "CARGOKIT_TOOL_TEMP_DIR", "${buildDir}/build_tool" + environment "CARGOKIT_MANIFEST_DIR", manifestDir + environment "CARGOKIT_CONFIGURATION", buildMode + environment "CARGOKIT_TARGET_TEMP_DIR", buildDir + environment "CARGOKIT_OUTPUT_DIR", outputDir + environment "CARGOKIT_NDK_VERSION", ndkVersion + environment "CARGOKIT_SDK_DIR", sdkDirectory + environment "CARGOKIT_COMPILE_SDK_VERSION", compileSdkVersion + environment "CARGOKIT_MIN_SDK_VERSION", minSdkVersion + environment "CARGOKIT_TARGET_PLATFORMS", targetPlatforms.join(",") + environment "CARGOKIT_JAVA_HOME", System.properties['java.home'] + } + } +} + +class CargoKitPlugin implements Plugin { + + static String file; + + private Plugin findFlutterPlugin(Project rootProject) { + _findFlutterPlugin(rootProject.childProjects) + } + + private Plugin _findFlutterPlugin(Map projects) { + for (project in projects) { + for (plugin in project.value.getPlugins()) { + if (plugin.class.name == "com.flutter.gradle.FlutterPlugin") { + return plugin; + } + } + def plugin = _findFlutterPlugin(project.value.childProjects); + if (plugin != null) { + return plugin; + } + } + return null; + } + + @Override + void apply(Project project) { + def plugin = findFlutterPlugin(project.rootProject); + + project.extensions.create("cargokit", CargoKitExtension) + + if (plugin == null) { + print("Flutter plugin not found, CargoKit plugin will not be applied.") + return; + } + + def cargoBuildDir = "${project.buildDir}/build" + + // Determine if the project is an application or library + def isApplication = plugin.project.plugins.hasPlugin('com.android.application') + def variants = isApplication ? plugin.project.android.applicationVariants : plugin.project.android.libraryVariants + + variants.all { variant -> + + final buildType = variant.buildType.name + + def cargoOutputDir = "${project.buildDir}/jniLibs/${buildType}"; + def jniLibs = project.android.sourceSets.maybeCreate(buildType).jniLibs; + jniLibs.srcDir(new File(cargoOutputDir)) + + def platforms = com.flutter.gradle.FlutterPluginUtils.getTargetPlatforms(project).collect() + + // Same thing addFlutterDependencies does in flutter.gradle + if (buildType == "debug") { + platforms.add("android-x86") + platforms.add("android-x64") + } + + // The task name depends on plugin properties, which are not available + // at this point + project.getGradle().afterProject { + def taskName = "cargokitCargoBuild${project.cargokit.libname.capitalize()}${buildType.capitalize()}"; + + if (project.tasks.findByName(taskName)) { + return + } + + if (plugin.project.android.ndkVersion == null) { + throw new GradleException("Please set 'android.ndkVersion' in 'app/build.gradle'.") + } + + def task = project.tasks.create(taskName, CargoKitBuildTask.class) { + buildMode = variant.buildType.name + buildDir = cargoBuildDir + outputDir = cargoOutputDir + ndkVersion = plugin.project.android.ndkVersion + sdkDirectory = plugin.project.android.sdkDirectory + minSdkVersion = plugin.project.android.defaultConfig.minSdkVersion.apiLevel as int + compileSdkVersion = plugin.project.android.compileSdkVersion.substring(8) as int + targetPlatforms = platforms + pluginFile = CargoKitPlugin.file + } + def onTask = { newTask -> + if (newTask.name == "merge${buildType.capitalize()}NativeLibs") { + newTask.dependsOn task + // Fix gradle 7.4.2 not picking up JNI library changes + newTask.outputs.upToDateWhen { false } + } + } + project.tasks.each onTask + project.tasks.whenTaskAdded onTask + } + } + } +} diff --git a/third_party/convex_flutter/cargokit/run_build_tool.cmd b/third_party/convex_flutter/cargokit/run_build_tool.cmd new file mode 100755 index 00000000..c45d0aa8 --- /dev/null +++ b/third_party/convex_flutter/cargokit/run_build_tool.cmd @@ -0,0 +1,91 @@ +@echo off +setlocal + +setlocal ENABLEDELAYEDEXPANSION + +SET BASEDIR=%~dp0 + +if not exist "%CARGOKIT_TOOL_TEMP_DIR%" ( + mkdir "%CARGOKIT_TOOL_TEMP_DIR%" +) +cd /D "%CARGOKIT_TOOL_TEMP_DIR%" + +SET BUILD_TOOL_PKG_DIR=%BASEDIR%build_tool +SET DART=%FLUTTER_ROOT%\bin\cache\dart-sdk\bin\dart + +set BUILD_TOOL_PKG_DIR_POSIX=%BUILD_TOOL_PKG_DIR:\=/% + +( + echo name: build_tool_runner + echo version: 1.0.0 + echo publish_to: none + echo. + echo environment: + echo sdk: '^>=3.0.0 ^<4.0.0' + echo. + echo dependencies: + echo build_tool: + echo path: %BUILD_TOOL_PKG_DIR_POSIX% +) >pubspec.yaml + +if not exist bin ( + mkdir bin +) + +( + echo import 'package:build_tool/build_tool.dart' as build_tool; + echo void main^(List^ args^) ^{ + echo build_tool.runMain^(args^); + echo ^} +) >bin\build_tool_runner.dart + +SET PRECOMPILED=bin\build_tool_runner.dill + +REM To detect changes in package we compare output of DIR /s (recursive) +set PREV_PACKAGE_INFO=.dart_tool\package_info.prev +set CUR_PACKAGE_INFO=.dart_tool\package_info.cur + +DIR "%BUILD_TOOL_PKG_DIR%" /s > "%CUR_PACKAGE_INFO%_orig" + +REM Last line in dir output is free space on harddrive. That is bound to +REM change between invocation so we need to remove it +( + Set "Line=" + For /F "UseBackQ Delims=" %%A In ("%CUR_PACKAGE_INFO%_orig") Do ( + SetLocal EnableDelayedExpansion + If Defined Line Echo !Line! + EndLocal + Set "Line=%%A") +) >"%CUR_PACKAGE_INFO%" +DEL "%CUR_PACKAGE_INFO%_orig" + +REM Compare current directory listing with previous +FC /B "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" > nul 2>&1 + +If %ERRORLEVEL% neq 0 ( + REM Changed - copy current to previous and remove precompiled kernel + if exist "%PREV_PACKAGE_INFO%" ( + DEL "%PREV_PACKAGE_INFO%" + ) + MOVE /Y "%CUR_PACKAGE_INFO%" "%PREV_PACKAGE_INFO%" + if exist "%PRECOMPILED%" ( + DEL "%PRECOMPILED%" + ) +) + +REM There is no CUR_PACKAGE_INFO it was renamed in previous step to %PREV_PACKAGE_INFO% +REM which means we need to do pub get and precompile +if not exist "%PRECOMPILED%" ( + echo Running pub get in "%cd%" + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart +) + +"%DART%" "%PRECOMPILED%" %* + +REM 253 means invalid snapshot version. +If %ERRORLEVEL% equ 253 ( + "%DART%" pub get --no-precompile + "%DART%" compile kernel bin/build_tool_runner.dart + "%DART%" "%PRECOMPILED%" %* +) diff --git a/third_party/convex_flutter/cargokit/run_build_tool.sh b/third_party/convex_flutter/cargokit/run_build_tool.sh new file mode 100755 index 00000000..24b0ed89 --- /dev/null +++ b/third_party/convex_flutter/cargokit/run_build_tool.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +set -e + +BASEDIR=$(dirname "$0") + +mkdir -p "$CARGOKIT_TOOL_TEMP_DIR" + +cd "$CARGOKIT_TOOL_TEMP_DIR" + +# Write a very simple bin package in temp folder that depends on build_tool package +# from Cargokit. This is done to ensure that we don't pollute Cargokit folder +# with .dart_tool contents. + +BUILD_TOOL_PKG_DIR="$BASEDIR/build_tool" + +if [[ -z $FLUTTER_ROOT ]]; then # not defined + DART=dart +else + DART="$FLUTTER_ROOT/bin/cache/dart-sdk/bin/dart" +fi + +cat << EOF > "pubspec.yaml" +name: build_tool_runner +version: 1.0.0 +publish_to: none + +environment: + sdk: '>=3.0.0 <4.0.0' + +dependencies: + build_tool: + path: "$BUILD_TOOL_PKG_DIR" +EOF + +mkdir -p "bin" + +cat << EOF > "bin/build_tool_runner.dart" +import 'package:build_tool/build_tool.dart' as build_tool; +void main(List args) { + build_tool.runMain(args); +} +EOF + +# Create alias for `shasum` if it does not exist and `sha1sum` exists +if ! [ -x "$(command -v shasum)" ] && [ -x "$(command -v sha1sum)" ]; then + shopt -s expand_aliases + alias shasum="sha1sum" +fi + +# Dart run will not cache any package that has a path dependency, which +# is the case for our build_tool_runner. So instead we precompile the package +# ourselves. +# To invalidate the cached kernel we use the hash of ls -LR of the build_tool +# package directory. This should be good enough, as the build_tool package +# itself is not meant to have any path dependencies. + +if [[ "$OSTYPE" == "darwin"* ]]; then + PACKAGE_HASH=$(ls -lTR "$BUILD_TOOL_PKG_DIR" | shasum) +else + PACKAGE_HASH=$(ls -lR --full-time "$BUILD_TOOL_PKG_DIR" | shasum) +fi + +PACKAGE_HASH_FILE=".package_hash" + +if [ -f "$PACKAGE_HASH_FILE" ]; then + EXISTING_HASH=$(cat "$PACKAGE_HASH_FILE") + if [ "$PACKAGE_HASH" != "$EXISTING_HASH" ]; then + rm "$PACKAGE_HASH_FILE" + fi +fi + +# Run pub get if needed. +if [ ! -f "$PACKAGE_HASH_FILE" ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + echo "$PACKAGE_HASH" > "$PACKAGE_HASH_FILE" +fi + +# Rebuild the tool if it was deleted by Android Studio +if [ ! -f "bin/build_tool_runner.dill" ]; then + "$DART" compile kernel bin/build_tool_runner.dart +fi + +set +e + +"$DART" bin/build_tool_runner.dill "$@" + +exit_code=$? + +# 253 means invalid snapshot version. +if [ $exit_code == 253 ]; then + "$DART" pub get --no-precompile + "$DART" compile kernel bin/build_tool_runner.dart + "$DART" bin/build_tool_runner.dill "$@" + exit_code=$? +fi + +exit $exit_code diff --git a/third_party/convex_flutter/example/HEALTH_CHECK.md b/third_party/convex_flutter/example/HEALTH_CHECK.md new file mode 100644 index 00000000..47296f78 --- /dev/null +++ b/third_party/convex_flutter/example/HEALTH_CHECK.md @@ -0,0 +1,87 @@ +# Health Check Query Setup (Optional) + +To use the health check functionality in this example app, you can optionally create a simple health check query in your Convex backend. This is **recommended but not required** - you can use any existing query instead. + +## Why Create a Dedicated Health Check? + +A dedicated health check query provides: +- **Lightweight**: No database queries or complex logic +- **Fast**: Minimal processing time +- **Clear Purpose**: Obviously for health checks +- **No Side Effects**: Doesn't modify any data +- **Best Practice**: Follows Convex and REST API conventions + +However, if you prefer, you can use any existing lightweight query (like `users:count` or `messages:list`) instead. + +## Creating the Health Check Query + +Create a file `convex/health.ts` in your Convex backend with the following content: + +```typescript +// convex/health.ts +import { query } from "./_generated/server"; + +export const ping = query({ + args: {}, + handler: async () => { + return "ok"; + }, +}); +``` + +This creates a lightweight query endpoint at `health:ping` that: +- Takes no arguments +- Returns a simple "ok" response +- Can be used for connection health checks +- Has minimal overhead + +## Using the Health Check + +The example app uses this query in two ways: + +### 1. Automatic Connection on Startup + +The HomeScreen triggers the health check automatically when the app starts: + +```dart +await ConvexClient.instance.query('health:ping', {}); +``` + +This establishes the WebSocket connection immediately, allowing the connection state to transition from "connecting" to "connected". + +### 2. Manual Connection Checks (Deprecated) + +The deprecated `checkConnection()` method uses the configured `healthCheckQuery`: + +```dart +await ConvexClient.initialize( + ConvexConfig( + healthCheckQuery: "health:ping", + ), +); + +final status = await ConvexClient.instance.checkConnection(); +``` + +## Why Use a Dedicated Health Check Query? + +1. **Lightweight**: No database queries or complex logic +2. **Fast**: Minimal processing time +3. **Idempotent**: Safe to call repeatedly +4. **No Side Effects**: Doesn't modify any data +5. **Clear Purpose**: Obvious what it's for + +## Alternative + +If you don't want to create a dedicated health check query, you can use any existing lightweight query from your backend: + +```dart +// Use any existing query +await ConvexClient.initialize( + ConvexConfig( + healthCheckQuery: "users:count", // Any lightweight query + ), +); +``` + +However, a dedicated health check endpoint is the recommended best practice. diff --git a/third_party/convex_flutter/example/README.md b/third_party/convex_flutter/example/README.md new file mode 100644 index 00000000..065bb074 --- /dev/null +++ b/third_party/convex_flutter/example/README.md @@ -0,0 +1,20 @@ +# convex_flutter_example + +Demonstrates how to use the convex_flutter plugin. + +## Usage Example + +Here's an example of how to send a message using a Convex mutation: + +```dart +await ConvexClient.instance.mutation( + name: "messages:send", + args: {"body": message, "author": "Singh"}, +); +``` + +Here's an example of how to query the backend: + +```dart +final result = await client.query('your_query'); +``` diff --git a/third_party/convex_flutter/example/analysis_options.yaml b/third_party/convex_flutter/example/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/third_party/convex_flutter/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/convex_flutter/example/android/app/build.gradle.kts b/third_party/convex_flutter/example/android/app/build.gradle.kts new file mode 100644 index 00000000..24f02a12 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.convex_flutter_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.convex_flutter_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/third_party/convex_flutter/example/android/app/src/debug/AndroidManifest.xml b/third_party/convex_flutter/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/third_party/convex_flutter/example/android/app/src/main/AndroidManifest.xml b/third_party/convex_flutter/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..0be63e96 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/android/app/src/main/kotlin/com/example/convex_flutter_example/MainActivity.kt b/third_party/convex_flutter/example/android/app/src/main/kotlin/com/example/convex_flutter_example/MainActivity.kt new file mode 100644 index 00000000..fe5ed24f --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/kotlin/com/example/convex_flutter_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.convex_flutter_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/third_party/convex_flutter/example/android/app/src/main/res/drawable-v21/launch_background.xml b/third_party/convex_flutter/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..f74085f3 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/third_party/convex_flutter/example/android/app/src/main/res/drawable/launch_background.xml b/third_party/convex_flutter/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/third_party/convex_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/third_party/convex_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/third_party/convex_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/third_party/convex_flutter/example/android/app/src/main/res/values-night/styles.xml b/third_party/convex_flutter/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/third_party/convex_flutter/example/android/app/src/main/res/values/styles.xml b/third_party/convex_flutter/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/third_party/convex_flutter/example/android/app/src/profile/AndroidManifest.xml b/third_party/convex_flutter/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/third_party/convex_flutter/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/third_party/convex_flutter/example/android/build.gradle.kts b/third_party/convex_flutter/example/android/build.gradle.kts new file mode 100644 index 00000000..89176ef4 --- /dev/null +++ b/third_party/convex_flutter/example/android/build.gradle.kts @@ -0,0 +1,21 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/third_party/convex_flutter/example/android/gradle.properties b/third_party/convex_flutter/example/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/third_party/convex_flutter/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/third_party/convex_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/third_party/convex_flutter/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ac3b4792 --- /dev/null +++ b/third_party/convex_flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/third_party/convex_flutter/example/android/settings.gradle.kts b/third_party/convex_flutter/example/android/settings.gradle.kts new file mode 100644 index 00000000..ab39a10a --- /dev/null +++ b/third_party/convex_flutter/example/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/third_party/convex_flutter/example/integration_test/simple_test.dart b/third_party/convex_flutter/example/integration_test/simple_test.dart new file mode 100644 index 00000000..f7e577bd --- /dev/null +++ b/third_party/convex_flutter/example/integration_test/simple_test.dart @@ -0,0 +1,11 @@ +import 'package:integration_test/integration_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() async => await RustLib.init()); + test('Can call rust function', () async { + // expect(greet(name: "Tom"), "Hello, Tom!"); + }); +} diff --git a/third_party/convex_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/third_party/convex_flutter/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/third_party/convex_flutter/example/ios/Flutter/Debug.xcconfig b/third_party/convex_flutter/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..ec97fc6f --- /dev/null +++ b/third_party/convex_flutter/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/third_party/convex_flutter/example/ios/Flutter/Release.xcconfig b/third_party/convex_flutter/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..c4855bfe --- /dev/null +++ b/third_party/convex_flutter/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/third_party/convex_flutter/example/ios/Podfile b/third_party/convex_flutter/example/ios/Podfile new file mode 100644 index 00000000..620e46eb --- /dev/null +++ b/third_party/convex_flutter/example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/third_party/convex_flutter/example/ios/Podfile.lock b/third_party/convex_flutter/example/ios/Podfile.lock new file mode 100644 index 00000000..9eaa971c --- /dev/null +++ b/third_party/convex_flutter/example/ios/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - convex_flutter (0.0.1): + - Flutter + - Flutter (1.0.0) + - integration_test (0.0.1): + - Flutter + +DEPENDENCIES: + - convex_flutter (from `.symlinks/plugins/convex_flutter/ios`) + - Flutter (from `Flutter`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + +EXTERNAL SOURCES: + convex_flutter: + :path: ".symlinks/plugins/convex_flutter/ios" + Flutter: + :path: Flutter + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + +SPEC CHECKSUMS: + convex_flutter: 8581c72fdb31ffdbfef9908f23f21668035a032a + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..2142bb7f --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,731 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B87EC92B90E5D1D4236EB5F4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A4045BB261C3A094CFA44EC /* Pods_Runner.framework */; }; + EDD20FBEB619D5A514A1C611 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF8F3F6DDE398E0F903BED73 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 18C621CFA8A07C85C53E155A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 1A4045BB261C3A094CFA44EC /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3E744F493A5CC5201BAB58EE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 4FE5B1E9D09AB9B4D3603DDC /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8FFBD8D853998A8FDAFE9693 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB5BA5233F77CB829DB7164C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + DD93ACA1C1F41A19BD464B8A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + EF8F3F6DDE398E0F903BED73 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7BF3221F20726CC94DF5C3AC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EDD20FBEB619D5A514A1C611 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B87EC92B90E5D1D4236EB5F4 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 59DCBF4450BCBEF58AD02F1C /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1A4045BB261C3A094CFA44EC /* Pods_Runner.framework */, + EF8F3F6DDE398E0F903BED73 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 9DC8167AFFCDEA8CCC7DD883 /* Pods */, + 59DCBF4450BCBEF58AD02F1C /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 9DC8167AFFCDEA8CCC7DD883 /* Pods */ = { + isa = PBXGroup; + children = ( + 3E744F493A5CC5201BAB58EE /* Pods-Runner.debug.xcconfig */, + 8FFBD8D853998A8FDAFE9693 /* Pods-Runner.release.xcconfig */, + 4FE5B1E9D09AB9B4D3603DDC /* Pods-Runner.profile.xcconfig */, + 18C621CFA8A07C85C53E155A /* Pods-RunnerTests.debug.xcconfig */, + AB5BA5233F77CB829DB7164C /* Pods-RunnerTests.release.xcconfig */, + DD93ACA1C1F41A19BD464B8A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + F1C5B2B1C58C5433761D2FB8 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 7BF3221F20726CC94DF5C3AC /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + FCB70B0446B240AB6783B5A6 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 79F53A30267DAF502589689D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 79F53A30267DAF502589689D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + F1C5B2B1C58C5433761D2FB8 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + FCB70B0446B240AB6783B5A6 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = A2N5J9H9QJ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 18C621CFA8A07C85C53E155A /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB5BA5233F77CB829DB7164C /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = DD93ACA1C1F41A19BD464B8A /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = A2N5J9H9QJ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = A2N5J9H9QJ; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/convex_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/third_party/convex_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/third_party/convex_flutter/example/ios/Runner/AppDelegate.swift b/third_party/convex_flutter/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..7353c41e Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..6ed2d933 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cd7b009 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..fe730945 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..321773cd Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..797d452e Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..502f463a Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..0ec30343 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..e9f5fea2 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..84ac32ae Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..8953cba0 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..0467bf12 Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/third_party/convex_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/third_party/convex_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/ios/Runner/Base.lproj/Main.storyboard b/third_party/convex_flutter/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/ios/Runner/Info.plist b/third_party/convex_flutter/example/ios/Runner/Info.plist new file mode 100644 index 00000000..a8459efc --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Convex Flutter + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + convex_flutter_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/third_party/convex_flutter/example/ios/Runner/Runner-Bridging-Header.h b/third_party/convex_flutter/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/third_party/convex_flutter/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/third_party/convex_flutter/example/ios/RunnerTests/RunnerTests.swift b/third_party/convex_flutter/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/third_party/convex_flutter/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/third_party/convex_flutter/example/lib/main.dart b/third_party/convex_flutter/example/lib/main.dart new file mode 100644 index 00000000..ace9715b --- /dev/null +++ b/third_party/convex_flutter/example/lib/main.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; +import 'screens/home_screen.dart'; +import 'screens/authentication_screen.dart'; +import 'screens/messaging_screen.dart'; +import 'screens/connection_screen.dart'; +import 'screens/advanced_screen.dart'; +import 'widgets/connection_status_indicator.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: "https://merry-grasshopper-563.convex.cloud", + clientId: "flutter-app-1.0", + operationTimeout: const Duration(seconds: 30), + healthCheckQuery: "health:ping", + ), + ); + + runApp(const ConvexExampleApp()); +} + +class ConvexExampleApp extends StatelessWidget { + const ConvexExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Convex Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + home: const MainNavigationScreen(), + ); + } +} + +class MainNavigationScreen extends StatefulWidget { + const MainNavigationScreen({super.key}); + + @override + State createState() => _MainNavigationScreenState(); +} + +class _MainNavigationScreenState extends State { + int _selectedIndex = 0; + + final List _screens = const [ + HomeScreen(), + AuthenticationScreen(), + MessagingScreen(), + ConnectionScreen(), + AdvancedScreen(), + ]; + + final List _navItems = const [ + NavigationItem( + icon: Icons.home, + label: 'Home', + description: 'Welcome and overview', + ), + NavigationItem( + icon: Icons.login, + label: 'Authentication', + description: 'JWT tokens, auto-refresh, auth state', + ), + NavigationItem( + icon: Icons.message, + label: 'Messaging', + description: 'Query, mutation, subscribe, live updates', + ), + NavigationItem( + icon: Icons.wifi, + label: 'Connection', + description: 'WebSocket state, health checks, reconnect', + ), + NavigationItem( + icon: Icons.settings, + label: 'Advanced', + description: 'Timeouts, actions, error handling, lifecycle', + ), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_navItems[_selectedIndex].label), + actions: const [ + ConnectionStatusIndicator(), + ], + ), + drawer: _buildDrawer(), + body: _screens[_selectedIndex], + ); + } + + Widget _buildDrawer() { + return Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.primaryContainer, + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.cloud, color: Colors.white, size: 48), + const SizedBox(height: 8), + const Text( + 'Convex Flutter', + style: TextStyle(color: Colors.white, fontSize: 24, + fontWeight: FontWeight.bold), + ), + const Text( + 'Example App', + style: TextStyle(color: Colors.white70, fontSize: 16), + ), + const Spacer(), + // Auth state indicator in drawer + StreamBuilder( + stream: ConvexClient.instance.authState, + initialData: false, + builder: (context, snapshot) { + final isAuth = snapshot.data ?? false; + return Row( + children: [ + Icon( + isAuth ? Icons.verified_user : Icons.person_off, + color: Colors.white70, + size: 16, + ), + const SizedBox(width: 4), + Text( + isAuth ? 'Authenticated' : 'Not Authenticated', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ); + }, + ), + ], + ), + ), + ...List.generate(_navItems.length, (index) { + final item = _navItems[index]; + return ListTile( + leading: Icon(item.icon), + title: Text(item.label), + subtitle: Text(item.description, style: const TextStyle(fontSize: 12)), + selected: _selectedIndex == index, + selectedTileColor: Theme.of(context).colorScheme.primaryContainer, + onTap: () { + setState(() => _selectedIndex = index); + Navigator.pop(context); + }, + ); + }), + const Divider(), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About'), + subtitle: const Text('Convex Flutter SDK v2.0.0', style: TextStyle(fontSize: 12)), + onTap: () { + Navigator.pop(context); + showAboutDialog( + context: context, + applicationName: 'Convex Flutter', + applicationVersion: '2.0.0', + applicationIcon: const Icon(Icons.cloud, size: 48), + children: const [ + Text('Comprehensive example app demonstrating all features of the Convex Flutter SDK.'), + SizedBox(height: 8), + Text('Features:\n' + '• Real-time subscriptions\n' + '• Authentication & token refresh\n' + '• WebSocket connection state\n' + '• Query, mutation, and action support\n' + '• Lifecycle management'), + ], + ); + }, + ), + ], + ), + ); + } +} + +class NavigationItem { + final IconData icon; + final String label; + final String description; + + const NavigationItem({ + required this.icon, + required this.label, + required this.description, + }); +} diff --git a/third_party/convex_flutter/example/lib/screens/advanced_screen.dart b/third_party/convex_flutter/example/lib/screens/advanced_screen.dart new file mode 100644 index 00000000..057ee594 --- /dev/null +++ b/third_party/convex_flutter/example/lib/screens/advanced_screen.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; +import 'package:convex_flutter/convex_flutter.dart'; + +class AdvancedScreen extends StatefulWidget { + const AdvancedScreen({super.key}); + + @override + State createState() => _AdvancedScreenState(); +} + +class _AdvancedScreenState extends State { + String? _timeoutResult; + bool _isTesting = false; + AppLifecycleEvent? _currentLifecycle; + final List _lifecycleHistory = []; + + @override + void initState() { + super.initState(); + ConvexClient.instance.lifecycleEvents.listen((event) { + setState(() { + _currentLifecycle = event; + _lifecycleHistory.insert(0, '${DateTime.now()}: ${event.name}'); + if (_lifecycleHistory.length > 10) _lifecycleHistory.removeLast(); + }); + }); + } + + Future _testTimeout(int seconds) async { + setState(() { + _isTesting = true; + _timeoutResult = null; + }); + + final stopwatch = Stopwatch()..start(); + try { + await ConvexClient.instance.query("messages:list", {}); + stopwatch.stop(); + setState(() { + _timeoutResult = 'Success in ${stopwatch.elapsedMilliseconds}ms'; + _isTesting = false; + }); + } on TimeoutException { + stopwatch.stop(); + setState(() { + _timeoutResult = 'Timeout after ${stopwatch.elapsedMilliseconds}ms'; + _isTesting = false; + }); + } catch (e) { + stopwatch.stop(); + setState(() { + _timeoutResult = 'Error: $e (${stopwatch.elapsedMilliseconds}ms)'; + _isTesting = false; + }); + } + } + + Future _testAction() async { + try { + final result = await ConvexClient.instance.action( + name: "myActions:doSomething", + args: {"param": "test"}, + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Action result: $result'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Action failed: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Timeout testing + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Timeout Testing', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + const Text('Test query execution with different timeouts', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: _isTesting ? null : () => _testTimeout(1), + child: const Text('1s timeout')), + ElevatedButton( + onPressed: _isTesting ? null : () => _testTimeout(5), + child: const Text('5s timeout')), + ElevatedButton( + onPressed: _isTesting ? null : () => _testTimeout(30), + child: const Text('30s timeout')), + ], + ), + if (_timeoutResult != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4)), + child: Text(_timeoutResult!, + style: const TextStyle(fontFamily: 'monospace')), + ), + ], + if (_isTesting) + const Padding( + padding: EdgeInsets.only(top: 12), + child: LinearProgressIndicator()), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Actions + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Server Actions', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + const Text('Execute backend actions (long-running operations)', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _testAction, + icon: const Icon(Icons.play_arrow), + label: const Text('Run Test Action')), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Lifecycle management + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('App Lifecycle', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + Row( + children: [ + const Text('Current State:', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(width: 8), + Chip( + label: Text(_currentLifecycle?.name ?? 'unknown'), + backgroundColor: _currentLifecycle == AppLifecycleEvent.resumed + ? Colors.green.shade100 + : Colors.grey.shade100), + ], + ), + const SizedBox(height: 12), + const Text('Recent Events:', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + if (_lifecycleHistory.isEmpty) + const Text('No events yet', style: TextStyle(color: Colors.grey)) + else + ...(_lifecycleHistory.map((event) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(event, + style: const TextStyle(fontSize: 12, fontFamily: 'monospace')), + ))), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Error handling examples + Card( + color: Colors.orange.shade50, + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.lightbulb_outline, color: Colors.orange), + SizedBox(width: 8), + Text('Error Handling Tips', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + ], + ), + SizedBox(height: 12), + Text('• Always wrap operations in try-catch\n' + '• Handle TimeoutException separately\n' + '• Check ClientError types for specifics\n' + '• Use subscription onError callbacks\n' + '• Monitor connection state changes', + style: TextStyle(fontSize: 13)), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/third_party/convex_flutter/example/lib/screens/authentication_screen.dart b/third_party/convex_flutter/example/lib/screens/authentication_screen.dart new file mode 100644 index 00000000..5187a891 --- /dev/null +++ b/third_party/convex_flutter/example/lib/screens/authentication_screen.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +class AuthenticationScreen extends StatefulWidget { + const AuthenticationScreen({super.key}); + + @override + State createState() => _AuthenticationScreenState(); +} + +class _AuthenticationScreenState extends State { + final TextEditingController _tokenController = TextEditingController(); + bool _isAuthenticated = false; + AuthHandleWrapper? _authHandle; + int _refreshCount = 0; + DateTime? _lastRefreshTime; + Map? _tokenClaims; + + @override + void initState() { + super.initState(); + ConvexClient.instance.authState.listen((isAuth) { + setState(() => _isAuthenticated = isAuth); + }); + } + + @override + void dispose() { + _tokenController.dispose(); + _authHandle?.dispose(); + super.dispose(); + } + + // Decode JWT to show claims + void _decodeToken(String token) { + try { + final parts = token.split('.'); + if (parts.length != 3) return; + final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); + setState(() => _tokenClaims = jsonDecode(payload)); + } catch (e) { + debugPrint('Error decoding token: $e'); + } + } + + Future _setAuth() async { + final token = _tokenController.text.trim(); + if (token.isEmpty) return; + + try { + await ConvexClient.instance.setAuth(token: token); + _decodeToken(token); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auth token set successfully'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'))); + } + } + + Future _setAuthWithRefresh() async { + try { + _authHandle?.dispose(); + _authHandle = await ConvexClient.instance.setAuthWithRefresh( + fetchToken: () async { + setState(() { + _refreshCount++; + _lastRefreshTime = DateTime.now(); + }); + // Mock token generation for demo + return 'mock_token_${DateTime.now().millisecondsSinceEpoch}'; + }, + onAuthChange: (isAuthenticated) { + debugPrint('Auth changed: $isAuthenticated'); + }, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auto-refresh enabled'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'))); + } + } + + Future _clearAuth() async { + try { + _authHandle?.dispose(); + _authHandle = null; + await ConvexClient.instance.clearAuth(); + setState(() { + _tokenClaims = null; + _refreshCount = 0; + _lastRefreshTime = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Auth cleared'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Auth status card + Card( + color: _isAuthenticated ? Colors.green.shade50 : Colors.grey.shade100, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(_isAuthenticated ? Icons.verified_user : Icons.person_off, + color: _isAuthenticated ? Colors.green : Colors.grey, size: 32), + const SizedBox(width: 12), + Text(_isAuthenticated ? 'Authenticated' : 'Not Authenticated', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Static token auth + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Static Token Auth', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + TextField( + controller: _tokenController, + decoration: const InputDecoration( + labelText: 'JWT Token', + border: OutlineInputBorder(), + hintText: 'Paste your JWT token here'), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _setAuth, + icon: const Icon(Icons.login), + label: const Text('Set Auth Token'), + ), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Auto-refresh auth + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Auto-Refresh Auth (Recommended)', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + const Text('Automatically refreshes tokens before expiry', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _setAuthWithRefresh, + icon: const Icon(Icons.autorenew), + label: const Text('Enable Auto-Refresh'), + ), + if (_refreshCount > 0) ...[ + const SizedBox(height: 12), + Text('Refresh count: $_refreshCount', + style: const TextStyle(fontFamily: 'monospace')), + if (_lastRefreshTime != null) + Text('Last refresh: ${_lastRefreshTime}', + style: const TextStyle(fontSize: 12)), + ], + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Token info + if (_tokenClaims != null) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Token Claims', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text(jsonEncode(_tokenClaims), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ], + ), + ), + ), + + const SizedBox(height: 16), + + // Clear auth + OutlinedButton.icon( + onPressed: _clearAuth, + icon: const Icon(Icons.logout), + label: const Text('Clear Auth'), + style: OutlinedButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ); + } +} diff --git a/third_party/convex_flutter/example/lib/screens/connection_screen.dart b/third_party/convex_flutter/example/lib/screens/connection_screen.dart new file mode 100644 index 00000000..4bf06e5b --- /dev/null +++ b/third_party/convex_flutter/example/lib/screens/connection_screen.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +class ConnectionScreen extends StatefulWidget { + const ConnectionScreen({super.key}); + + @override + State createState() => _ConnectionScreenState(); +} + +class _ConnectionScreenState extends State { + final List _stateHistory = []; + StreamSubscription? _connectionSubscription; + + @override + void initState() { + super.initState(); + _connectionSubscription = ConvexClient.instance.connectionState.listen((state) { + if (mounted) { + setState(() { + _stateHistory.insert(0, ConnectionEvent( + state: state, + timestamp: DateTime.now())); + if (_stateHistory.length > 20) _stateHistory.removeLast(); + }); + } + }); + } + + @override + void dispose() { + _connectionSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCurrentStateCard(), + const SizedBox(height: 16), + _buildFeatureCard(), + const SizedBox(height: 16), + _buildHistoryCard(), + ], + ), + ); + } + + Widget _buildCurrentStateCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('WebSocket Connection State', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const Text('Real-time state from underlying WebSocket', + style: TextStyle(fontSize: 12, color: Colors.grey)), + const SizedBox(height: 16), + StreamBuilder( + stream: ConvexClient.instance.connectionState, + initialData: ConvexClient.instance.currentConnectionState, + builder: (context, snapshot) { + final state = snapshot.data!; + final isConnected = state == WebSocketConnectionState.connected; + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isConnected ? Colors.green.shade50 : Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isConnected ? Colors.green : Colors.orange, width: 2)), + child: Row( + children: [ + Icon(isConnected ? Icons.cloud_done : Icons.cloud_sync, + color: isConnected ? Colors.green : Colors.orange, size: 48), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(state.name.toUpperCase(), + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, + color: isConnected ? Colors.green : Colors.orange)), + Text(isConnected ? 'WebSocket is open' : 'WebSocket connecting', + style: const TextStyle(fontSize: 12)), + ], + ), + ), + ], + ), + ); + }), + const SizedBox(height: 12), + Text('isConnected: ${ConvexClient.instance.isConnected}', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12)), + ], + ), + ), + ); + } + + Widget _buildFeatureCard() { + return Card( + color: Colors.blue.shade50, + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('✨ New Feature: Real-time Connection State', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text('• Automatic state updates without polling\n' + '• Two states: Connected and Connecting\n' + '• Reflects actual WebSocket connection\n' + '• Access via connectionState stream\n' + '• Convenience getter: isConnected', + style: TextStyle(fontSize: 13)), + ], + ), + ), + ); + } + + Widget _buildHistoryCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('State Change History', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + TextButton.icon( + onPressed: () => setState(() => _stateHistory.clear()), + icon: const Icon(Icons.clear_all, size: 16), + label: const Text('Clear')), + ], + ), + const SizedBox(height: 8), + if (_stateHistory.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Center(child: Text('No state changes yet', + style: TextStyle(color: Colors.grey)))) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _stateHistory.length, + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (context, index) { + final event = _stateHistory[index]; + final isConnected = event.state == WebSocketConnectionState.connected; + return ListTile( + leading: Icon(isConnected ? Icons.cloud_done : Icons.cloud_sync, + color: isConnected ? Colors.green : Colors.orange), + title: Text(event.state.name.toUpperCase()), + subtitle: Text(_formatTime(event.timestamp)), + trailing: Text(_timeAgo(event.timestamp), + style: const TextStyle(fontSize: 11, color: Colors.grey)), + ); + }, + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + return '${dt.hour.toString().padLeft(2, '0')}:' + '${dt.minute.toString().padLeft(2, '0')}:' + '${dt.second.toString().padLeft(2, '0')}'; + } + + String _timeAgo(DateTime dt) { + final diff = DateTime.now().difference(dt); + if (diff.inSeconds < 60) return '${diff.inSeconds}s ago'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + return '${diff.inHours}h ago'; + } +} + +class ConnectionEvent { + final WebSocketConnectionState state; + final DateTime timestamp; + ConnectionEvent({required this.state, required this.timestamp}); +} diff --git a/third_party/convex_flutter/example/lib/screens/home_screen.dart b/third_party/convex_flutter/example/lib/screens/home_screen.dart new file mode 100644 index 00000000..1612dea2 --- /dev/null +++ b/third_party/convex_flutter/example/lib/screens/home_screen.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + // Trigger auto-connection on app startup + _establishConnection(); + } + + Future _establishConnection() async { + try { + // Use a health check query to establish the WebSocket connection + // Create this query in your Convex backend: convex/health.ts + await ConvexClient.instance.query( + 'health:ping', + {}, + ); + debugPrint('HomeScreen: Auto-connection established via health check query'); + } catch (e) { + debugPrint('HomeScreen: Auto-connection failed: $e'); + // Connection will retry automatically via Convex client + } + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Icon(Icons.rocket_launch, size: 64, + color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 16), + Text('Welcome to Convex Flutter', + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center), + const SizedBox(height: 8), + const Text( + 'Explore all SDK capabilities', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey)), + ], + ), + ), + ), + const SizedBox(height: 24), + Text('Features', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 16), + _FeatureCard(icon: Icons.login, title: 'Authentication', + description: 'JWT tokens, auto-refresh',color: Colors.purple), + _FeatureCard(icon: Icons.message, title: 'Real-time Messaging', + description: 'Subscriptions, queries, mutations', color: Colors.blue), + _FeatureCard(icon: Icons.wifi, title: 'Connection State', + description: 'WebSocket state tracking', color: Colors.green), + _FeatureCard(icon: Icons.settings, title: 'Advanced', + description: 'Timeouts, actions, lifecycle', color: Colors.orange), + const SizedBox(height: 24), + _buildStatusSummary(), + ], + ), + ); + } + + Widget _buildStatusSummary() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Current Status', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 12), + StreamBuilder( + stream: ConvexClient.instance.connectionState, + builder: (context, snapshot) { + final state = snapshot.data; + return _StatusRow(icon: Icons.wifi, label: 'Connection', + value: state?.name ?? 'Unknown', + color: state == WebSocketConnectionState.connected + ? Colors.green : Colors.orange); + }), + const Divider(), + StreamBuilder( + stream: ConvexClient.instance.authState, + builder: (context, snapshot) { + final isAuth = snapshot.data ?? false; + return _StatusRow(icon: Icons.lock, label: 'Auth', + value: isAuth ? 'Yes' : 'No', + color: isAuth ? Colors.green : Colors.grey); + }), + ], + ), + ), + ); + } +} + +class _FeatureCard extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final Color color; + const _FeatureCard({required this.icon, required this.title, + required this.description, required this.color}); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: Icon(icon, color: color, size: 32), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Text(description), + ), + ); + } +} + +class _StatusRow extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color color; + const _StatusRow({required this.icon, required this.label, + required this.value, required this.color}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(width: 12), + Text(label, style: const TextStyle(fontWeight: FontWeight.w500)), + const Spacer(), + Text(value, style: TextStyle(color: color)), + ], + ); + } +} diff --git a/third_party/convex_flutter/example/lib/screens/messaging_screen.dart b/third_party/convex_flutter/example/lib/screens/messaging_screen.dart new file mode 100644 index 00000000..757bd2cd --- /dev/null +++ b/third_party/convex_flutter/example/lib/screens/messaging_screen.dart @@ -0,0 +1,206 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +class MessagingScreen extends StatefulWidget { + const MessagingScreen({super.key}); + + @override + State createState() => _MessagingScreenState(); +} + +class _MessagingScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + final String _currentUserId = "Flutter App"; + List> _messages = []; + SubscriptionHandle? _subscriptionHandle; + bool _isSubscribed = false; + int _messageCount = 0; + + @override + void initState() { + super.initState(); + _startSubscription(); + } + + @override + void dispose() { + _messageController.dispose(); + _subscriptionHandle?.cancel(); + super.dispose(); + } + + Future _startSubscription() async { + if (_subscriptionHandle != null) return; + + try { + _subscriptionHandle = await ConvexClient.instance.subscribe( + name: "messages:list", + args: {}, + onUpdate: (value) { + if (!mounted) return; + final List jsonList = jsonDecode(value); + final List> parsedMessages = + jsonList.map((e) => e as Map).toList(); + setState(() { + _messages = parsedMessages; + _messageCount = parsedMessages.length; + _isSubscribed = true; + }); + }, + onError: (message, value) { + if (!mounted) return; + debugPrint("Subscription error: $message"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $message'))); + }, + ); + if (mounted) setState(() => _isSubscribed = true); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to subscribe: $e'))); + } + } + + void _stopSubscription() { + _subscriptionHandle?.cancel(); + _subscriptionHandle = null; + setState(() { + _isSubscribed = false; + _messages.clear(); + }); + } + + Future _sendMessage() async { + final message = _messageController.text.trim(); + if (message.isEmpty) return; + + try { + await ConvexClient.instance.mutation( + name: "messages:send", + args: {"body": message, "author": _currentUserId}, + ); + _messageController.clear(); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to send: $e'))); + } + } + + Future _queryMessages() async { + try { + final result = await ConvexClient.instance.query("messages:list", {}); + final List jsonList = jsonDecode(result); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Query returned ${jsonList.length} messages'))); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Query failed: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Status bar + Container( + padding: const EdgeInsets.all(12), + color: _isSubscribed ? Colors.green.shade100 : Colors.orange.shade100, + child: Row( + children: [ + Icon(_isSubscribed ? Icons.wifi : Icons.wifi_off, + color: _isSubscribed ? Colors.green : Colors.orange), + const SizedBox(width: 8), + Text(_isSubscribed ? 'Live Updates' : 'Paused', + style: const TextStyle(fontWeight: FontWeight.bold)), + const Spacer(), + Text('$_messageCount messages'), + const SizedBox(width: 8), + IconButton( + icon: Icon(_isSubscribed ? Icons.pause : Icons.play_arrow), + onPressed: _isSubscribed ? _stopSubscription : _startSubscription, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _queryMessages, + tooltip: 'Query messages'), + ], + ), + ), + + // Message list + Expanded( + child: _messages.isEmpty + ? const Center(child: Text('No messages yet')) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + final isMyMessage = message['userId'] == _currentUserId || + message['author'] == _currentUserId; + + return Align( + alignment: isMyMessage + ? Alignment.centerRight + : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.7), + decoration: BoxDecoration( + color: isMyMessage ? Colors.blue[100] : Colors.grey[200], + borderRadius: BorderRadius.circular(12).copyWith( + bottomRight: isMyMessage ? Radius.zero : const Radius.circular(12), + bottomLeft: isMyMessage ? const Radius.circular(12) : Radius.zero), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message['body'] ?? '', + style: const TextStyle(fontSize: 16)), + if (message['author'] != null) + Text('- ${message['author']}', + style: const TextStyle(fontSize: 10, color: Colors.grey)), + ], + ), + ), + ); + }, + ), + ), + + // Input area + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [BoxShadow(color: Colors.grey.withOpacity(0.2), + spreadRadius: 1, blurRadius: 3)], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8)), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: _sendMessage, + icon: const Icon(Icons.send), + color: Colors.blue), + ], + ), + ), + ], + ); + } +} diff --git a/third_party/convex_flutter/example/lib/widgets/connection_status_indicator.dart b/third_party/convex_flutter/example/lib/widgets/connection_status_indicator.dart new file mode 100644 index 00000000..6acdd79a --- /dev/null +++ b/third_party/convex_flutter/example/lib/widgets/connection_status_indicator.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; + +/// A reusable widget that displays the current WebSocket connection state. +/// +/// This widget listens to the real-time connection state stream and +/// displays a colored chip indicator in the app bar. +class ConnectionStatusIndicator extends StatelessWidget { + const ConnectionStatusIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: ConvexClient.instance.connectionState, + initialData: ConvexClient.instance.currentConnectionState, + builder: (context, snapshot) { + print('snapshot: ${snapshot.data}'); + final state = snapshot.data ?? WebSocketConnectionState.connecting; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Chip( + avatar: Icon( + _getIcon(state), + size: 16, + color: _getColor(state), + ), + label: Text( + _getLabel(state), + style: const TextStyle(fontSize: 11), + ), + backgroundColor: Colors.white.withOpacity(0.9), + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + ); + }, + ); + } + + IconData _getIcon(WebSocketConnectionState state) { + switch (state) { + case WebSocketConnectionState.connected: + return Icons.cloud_done; + case WebSocketConnectionState.connecting: + return Icons.cloud_sync; + } + } + + Color _getColor(WebSocketConnectionState state) { + switch (state) { + case WebSocketConnectionState.connected: + return Colors.green; + case WebSocketConnectionState.connecting: + return Colors.orange; + } + } + + String _getLabel(WebSocketConnectionState state) { + switch (state) { + case WebSocketConnectionState.connected: + return 'Connected'; + case WebSocketConnectionState.connecting: + return 'Connecting'; + } + } +} diff --git a/third_party/convex_flutter/example/linux/CMakeLists.txt b/third_party/convex_flutter/example/linux/CMakeLists.txt new file mode 100644 index 00000000..a0288d7b --- /dev/null +++ b/third_party/convex_flutter/example/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "convex_flutter_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.convex_flutter") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/third_party/convex_flutter/example/linux/flutter/CMakeLists.txt b/third_party/convex_flutter/example/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/third_party/convex_flutter/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.cc b/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..e71a16d2 --- /dev/null +++ b/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.h b/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/third_party/convex_flutter/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/convex_flutter/example/linux/flutter/generated_plugins.cmake b/third_party/convex_flutter/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..410e76ed --- /dev/null +++ b/third_party/convex_flutter/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/third_party/convex_flutter/example/linux/runner/CMakeLists.txt b/third_party/convex_flutter/example/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/third_party/convex_flutter/example/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/third_party/convex_flutter/example/linux/runner/main.cc b/third_party/convex_flutter/example/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/third_party/convex_flutter/example/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/third_party/convex_flutter/example/linux/runner/my_application.cc b/third_party/convex_flutter/example/linux/runner/my_application.cc new file mode 100644 index 00000000..e73fb97d --- /dev/null +++ b/third_party/convex_flutter/example/linux/runner/my_application.cc @@ -0,0 +1,130 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "convex_flutter_example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "convex_flutter_example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/third_party/convex_flutter/example/linux/runner/my_application.h b/third_party/convex_flutter/example/linux/runner/my_application.h new file mode 100644 index 00000000..72271d5e --- /dev/null +++ b/third_party/convex_flutter/example/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/third_party/convex_flutter/example/macos/Flutter/Flutter-Debug.xcconfig b/third_party/convex_flutter/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..4b81f9b2 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/convex_flutter/example/macos/Flutter/Flutter-Release.xcconfig b/third_party/convex_flutter/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..5caa9d15 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/third_party/convex_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift b/third_party/convex_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..cccf817a --- /dev/null +++ b/third_party/convex_flutter/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/third_party/convex_flutter/example/macos/Podfile b/third_party/convex_flutter/example/macos/Podfile new file mode 100644 index 00000000..ff5ddb3b --- /dev/null +++ b/third_party/convex_flutter/example/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/third_party/convex_flutter/example/macos/Podfile.lock b/third_party/convex_flutter/example/macos/Podfile.lock new file mode 100644 index 00000000..29876fc6 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - convex_flutter (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - convex_flutter (from `Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + convex_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + convex_flutter: a9d12846e80c8a2238282775fa9eb9c906efcea4 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.pbxproj b/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..fb7849d4 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 5F83E745364ED6BF9E7FF659 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E71D87C3EB6720BF44F05D8B /* Pods_Runner.framework */; }; + BFFE63166C502BDD7B20AF71 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2557E6C7825F3FC95F3CD0D8 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 2557E6C7825F3FC95F3CD0D8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 32099594734A516286FC2264 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* convex_flutter_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = convex_flutter_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 50D06C36722B18DAB0BE360E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 53F61EED7E54B288A8BC0ABC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 5571B207020C2E29ECBC07B5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9860AB98CAFCFA22A12134B5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + D63BC546645F3C3533E4F378 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E71D87C3EB6720BF44F05D8B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BFFE63166C502BDD7B20AF71 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5F83E745364ED6BF9E7FF659 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 579DD7398EE076E17B680A75 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* convex_flutter_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 579DD7398EE076E17B680A75 /* Pods */ = { + isa = PBXGroup; + children = ( + 53F61EED7E54B288A8BC0ABC /* Pods-Runner.debug.xcconfig */, + 5571B207020C2E29ECBC07B5 /* Pods-Runner.release.xcconfig */, + 50D06C36722B18DAB0BE360E /* Pods-Runner.profile.xcconfig */, + 32099594734A516286FC2264 /* Pods-RunnerTests.debug.xcconfig */, + 9860AB98CAFCFA22A12134B5 /* Pods-RunnerTests.release.xcconfig */, + D63BC546645F3C3533E4F378 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + E71D87C3EB6720BF44F05D8B /* Pods_Runner.framework */, + 2557E6C7825F3FC95F3CD0D8 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 4FE9D07133D15C36BF94B9EC /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9B884211EDE4031D884FE3A3 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 4B187D1D2999DC2E01B75E0C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* convex_flutter_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 4B187D1D2999DC2E01B75E0C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4FE9D07133D15C36BF94B9EC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9B884211EDE4031D884FE3A3 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 32099594734A516286FC2264 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/convex_flutter_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/convex_flutter_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9860AB98CAFCFA22A12134B5 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/convex_flutter_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/convex_flutter_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D63BC546645F3C3533E4F378 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/convex_flutter_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/convex_flutter_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/convex_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/third_party/convex_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..6266fa98 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/third_party/convex_flutter/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/third_party/convex_flutter/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/third_party/convex_flutter/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/third_party/convex_flutter/example/macos/Runner/AppDelegate.swift b/third_party/convex_flutter/example/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/third_party/convex_flutter/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/third_party/convex_flutter/example/macos/Runner/Base.lproj/MainMenu.xib b/third_party/convex_flutter/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/third_party/convex_flutter/example/macos/Runner/Configs/AppInfo.xcconfig b/third_party/convex_flutter/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..0110a435 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = convex_flutter_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.convexFlutterExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/third_party/convex_flutter/example/macos/Runner/Configs/Debug.xcconfig b/third_party/convex_flutter/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/third_party/convex_flutter/example/macos/Runner/Configs/Release.xcconfig b/third_party/convex_flutter/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/third_party/convex_flutter/example/macos/Runner/Configs/Warnings.xcconfig b/third_party/convex_flutter/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/third_party/convex_flutter/example/macos/Runner/DebugProfile.entitlements b/third_party/convex_flutter/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..08c3ab17 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/third_party/convex_flutter/example/macos/Runner/Info.plist b/third_party/convex_flutter/example/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/third_party/convex_flutter/example/macos/Runner/MainFlutterWindow.swift b/third_party/convex_flutter/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/third_party/convex_flutter/example/macos/Runner/Release.entitlements b/third_party/convex_flutter/example/macos/Runner/Release.entitlements new file mode 100644 index 00000000..64cabb4e --- /dev/null +++ b/third_party/convex_flutter/example/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/third_party/convex_flutter/example/macos/RunnerTests/RunnerTests.swift b/third_party/convex_flutter/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/third_party/convex_flutter/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/third_party/convex_flutter/example/pubspec.yaml b/third_party/convex_flutter/example/pubspec.yaml new file mode 100644 index 00000000..0c1baaac --- /dev/null +++ b/third_party/convex_flutter/example/pubspec.yaml @@ -0,0 +1,99 @@ +name: convex_flutter_example +description: "Demonstrates how to use the convex_flutter plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + convex_flutter: + # When depending on this package from a real application you should use: + # convex_flutter: ^x.y.z + # See https://dart.dev/tools/pub/dependencies#version-constraints + # The example app is bundled with the plugin so we use a path dependency on + # the parent directory to use the current plugin's version. + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + integration_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/third_party/convex_flutter/example/screenshots/app_screenshot.png b/third_party/convex_flutter/example/screenshots/app_screenshot.png new file mode 100644 index 00000000..684b201c Binary files /dev/null and b/third_party/convex_flutter/example/screenshots/app_screenshot.png differ diff --git a/third_party/convex_flutter/example/screenshots/messaging_screenshot.png b/third_party/convex_flutter/example/screenshots/messaging_screenshot.png new file mode 100644 index 00000000..45d93485 Binary files /dev/null and b/third_party/convex_flutter/example/screenshots/messaging_screenshot.png differ diff --git a/third_party/convex_flutter/example/test/widget_test.dart b/third_party/convex_flutter/example/test/widget_test.dart new file mode 100644 index 00000000..9266deaa --- /dev/null +++ b/third_party/convex_flutter/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:convex_flutter_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/third_party/convex_flutter/example/web/favicon.png b/third_party/convex_flutter/example/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/third_party/convex_flutter/example/web/favicon.png differ diff --git a/third_party/convex_flutter/example/web/icons/Icon-192.png b/third_party/convex_flutter/example/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/third_party/convex_flutter/example/web/icons/Icon-192.png differ diff --git a/third_party/convex_flutter/example/web/icons/Icon-512.png b/third_party/convex_flutter/example/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/third_party/convex_flutter/example/web/icons/Icon-512.png differ diff --git a/third_party/convex_flutter/example/web/icons/Icon-maskable-192.png b/third_party/convex_flutter/example/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/third_party/convex_flutter/example/web/icons/Icon-maskable-192.png differ diff --git a/third_party/convex_flutter/example/web/icons/Icon-maskable-512.png b/third_party/convex_flutter/example/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/third_party/convex_flutter/example/web/icons/Icon-maskable-512.png differ diff --git a/third_party/convex_flutter/example/web/index.html b/third_party/convex_flutter/example/web/index.html new file mode 100644 index 00000000..f01c73e0 --- /dev/null +++ b/third_party/convex_flutter/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + convex_flutter_example + + + + + + diff --git a/third_party/convex_flutter/example/web/manifest.json b/third_party/convex_flutter/example/web/manifest.json new file mode 100644 index 00000000..b6c15434 --- /dev/null +++ b/third_party/convex_flutter/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "convex_flutter_example", + "short_name": "convex_flutter_example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/third_party/convex_flutter/example/windows/CMakeLists.txt b/third_party/convex_flutter/example/windows/CMakeLists.txt new file mode 100644 index 00000000..110b0373 --- /dev/null +++ b/third_party/convex_flutter/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(convex_flutter_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "convex_flutter_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/third_party/convex_flutter/example/windows/flutter/CMakeLists.txt b/third_party/convex_flutter/example/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/third_party/convex_flutter/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.cc b/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..8b6d4680 --- /dev/null +++ b/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.h b/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/third_party/convex_flutter/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/third_party/convex_flutter/example/windows/flutter/generated_plugins.cmake b/third_party/convex_flutter/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..06a20a90 --- /dev/null +++ b/third_party/convex_flutter/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/third_party/convex_flutter/example/windows/runner/CMakeLists.txt b/third_party/convex_flutter/example/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/third_party/convex_flutter/example/windows/runner/Runner.rc b/third_party/convex_flutter/example/windows/runner/Runner.rc new file mode 100644 index 00000000..4bb12308 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "convex_flutter_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "convex_flutter_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "convex_flutter_example.exe" "\0" + VALUE "ProductName", "convex_flutter_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/third_party/convex_flutter/example/windows/runner/flutter_window.cpp b/third_party/convex_flutter/example/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/third_party/convex_flutter/example/windows/runner/flutter_window.h b/third_party/convex_flutter/example/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/third_party/convex_flutter/example/windows/runner/main.cpp b/third_party/convex_flutter/example/windows/runner/main.cpp new file mode 100644 index 00000000..8197cad9 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"convex_flutter_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/third_party/convex_flutter/example/windows/runner/resource.h b/third_party/convex_flutter/example/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/third_party/convex_flutter/example/windows/runner/resources/app_icon.ico b/third_party/convex_flutter/example/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..c04e20ca Binary files /dev/null and b/third_party/convex_flutter/example/windows/runner/resources/app_icon.ico differ diff --git a/third_party/convex_flutter/example/windows/runner/runner.exe.manifest b/third_party/convex_flutter/example/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/third_party/convex_flutter/example/windows/runner/utils.cpp b/third_party/convex_flutter/example/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/third_party/convex_flutter/example/windows/runner/utils.h b/third_party/convex_flutter/example/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/third_party/convex_flutter/example/windows/runner/win32_window.cpp b/third_party/convex_flutter/example/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/third_party/convex_flutter/example/windows/runner/win32_window.h b/third_party/convex_flutter/example/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/third_party/convex_flutter/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/third_party/convex_flutter/flutter_rust_bridge.yaml b/third_party/convex_flutter/flutter_rust_bridge.yaml new file mode 100644 index 00000000..9de311bb --- /dev/null +++ b/third_party/convex_flutter/flutter_rust_bridge.yaml @@ -0,0 +1,3 @@ +rust_input: crate +rust_root: rust/ +dart_output: lib/src/rust diff --git a/third_party/convex_flutter/ios/Classes/dummy_file.c b/third_party/convex_flutter/ios/Classes/dummy_file.c new file mode 100644 index 00000000..e06dab99 --- /dev/null +++ b/third_party/convex_flutter/ios/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/third_party/convex_flutter/ios/convex_flutter.podspec b/third_party/convex_flutter/ios/convex_flutter.podspec new file mode 100644 index 00000000..a40f968e --- /dev/null +++ b/third_party/convex_flutter/ios/convex_flutter.podspec @@ -0,0 +1,45 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint convex_flutter.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'convex_flutter' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '11.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust convex_flutter', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/libconvex_flutter.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libconvex_flutter.a', + } +end diff --git a/third_party/convex_flutter/lib/convex_flutter.dart b/third_party/convex_flutter/lib/convex_flutter.dart new file mode 100644 index 00000000..f88b0ad1 --- /dev/null +++ b/third_party/convex_flutter/lib/convex_flutter.dart @@ -0,0 +1,9 @@ +library; + +export 'src/rust/lib.dart'; +export 'src/rust/frb_generated.dart' show RustLib; +export 'src/convex_client.dart' + show ConvexClient, AuthHandleWrapper, TokenFetcher, AuthStateCallback; +export 'src/convex_config.dart' show ConvexConfig; +export 'src/connection_status.dart' show ConnectionStatus; +export 'src/app_lifecycle_event.dart' show AppLifecycleEvent; diff --git a/third_party/convex_flutter/lib/convex_flutter_web.dart b/third_party/convex_flutter/lib/convex_flutter_web.dart new file mode 100644 index 00000000..b5546e23 --- /dev/null +++ b/third_party/convex_flutter/lib/convex_flutter_web.dart @@ -0,0 +1,18 @@ +/// Web platform implementation of convex_flutter plugin. +/// +/// This file is automatically registered by Flutter when building for web. +library convex_flutter_web; + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +/// The web implementation of [ConvexFlutterPlatform]. +/// +/// This class is automatically registered when building for web. +/// The actual web functionality is provided by [WebConvexClient]. +class ConvexFlutterWeb { + /// Factory constructor for web platform plugin registration. + static void registerWith(Registrar registrar) { + // No platform channel needed for web - we use pure Dart WebSocket implementation + // The ConvexClient automatically selects WebConvexClient when kIsWeb is true + } +} diff --git a/third_party/convex_flutter/lib/src/app_lifecycle_event.dart b/third_party/convex_flutter/lib/src/app_lifecycle_event.dart new file mode 100644 index 00000000..e22b48df --- /dev/null +++ b/third_party/convex_flutter/lib/src/app_lifecycle_event.dart @@ -0,0 +1,26 @@ +/// Enum representing Flutter app lifecycle state changes. +/// +/// These events are emitted by the ConvexClient when the app +/// transitions between different lifecycle states. +/// +/// Example usage: +/// ```dart +/// ConvexClient.instance.lifecycleEvents.listen((event) { +/// if (event == AppLifecycleEvent.resumed) { +/// print('App came to foreground'); +/// } +/// }); +/// ``` +enum AppLifecycleEvent { + /// App has come to the foreground and is visible to the user + resumed, + + /// App is in the background but still running + paused, + + /// App is inactive (e.g., during a phone call or system dialog) + inactive, + + /// App is being terminated + detached, +} diff --git a/third_party/convex_flutter/lib/src/app_lifecycle_observer.dart b/third_party/convex_flutter/lib/src/app_lifecycle_observer.dart new file mode 100644 index 00000000..5dab7e64 --- /dev/null +++ b/third_party/convex_flutter/lib/src/app_lifecycle_observer.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:convex_flutter/src/app_lifecycle_event.dart'; + +/// Observes Flutter app lifecycle state changes and emits events. +/// +/// This class uses Flutter's WidgetsBindingObserver to monitor +/// when the app transitions between foreground, background, and +/// other lifecycle states. +/// +/// The observer automatically registers itself with WidgetsBinding +/// upon creation and should be disposed when no longer needed. +class AppLifecycleObserver with WidgetsBindingObserver { + /// Callback function invoked when app lifecycle state changes + final void Function(AppLifecycleEvent) onLifecycleChange; + + /// Creates a new lifecycle observer with the specified callback. + /// + /// The observer automatically registers itself with WidgetsBinding. + AppLifecycleObserver({required this.onLifecycleChange}) { + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + final event = switch (state) { + AppLifecycleState.resumed => AppLifecycleEvent.resumed, + AppLifecycleState.paused => AppLifecycleEvent.paused, + AppLifecycleState.inactive => AppLifecycleEvent.inactive, + AppLifecycleState.detached => AppLifecycleEvent.detached, + _ => null, + }; + + if (event != null) { + onLifecycleChange(event); + } + } + + /// Disposes the observer and unregisters it from WidgetsBinding. + /// + /// Call this method when the observer is no longer needed to prevent + /// memory leaks. + void dispose() { + WidgetsBinding.instance.removeObserver(this); + } +} diff --git a/third_party/convex_flutter/lib/src/connection_status.dart b/third_party/convex_flutter/lib/src/connection_status.dart new file mode 100644 index 00000000..5ab60500 --- /dev/null +++ b/third_party/convex_flutter/lib/src/connection_status.dart @@ -0,0 +1,15 @@ +/// Enum representing the possible connection states when checking +/// connectivity to the Convex backend. +enum ConnectionStatus { + /// Connection check has not been performed yet + unknown, + + /// Successfully connected to the backend + connected, + + /// Connection check timed out + timeout, + + /// An error occurred during the connection check + error, +} diff --git a/third_party/convex_flutter/lib/src/convex_client.dart b/third_party/convex_flutter/lib/src/convex_client.dart new file mode 100644 index 00000000..b98ff3af --- /dev/null +++ b/third_party/convex_flutter/lib/src/convex_client.dart @@ -0,0 +1,421 @@ +import 'dart:async'; + +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; +import 'package:convex_flutter/src/impl/convex_client_factory.dart'; +import 'package:convex_flutter/src/rust/lib.dart' + show WebSocketConnectionState, SubscriptionHandle, AuthHandle; +import 'package:convex_flutter/src/connection_status.dart'; +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/app_lifecycle_event.dart'; + +/// Callback type for fetching authentication tokens. +/// Should return a JWT token string, or null to sign out. +typedef TokenFetcher = Future Function(); + +/// Callback type for authentication state changes. +typedef AuthStateCallback = void Function(bool isAuthenticated); + +/// A client for interacting with a Convex backend service. +/// +/// The ConvexClient provides methods for executing queries, mutations, actions and +/// managing real-time subscriptions with a Convex backend. +/// +/// This client automatically selects the appropriate implementation based on platform: +/// - **Mobile/Desktop** (Android, iOS, macOS, Windows, Linux): Uses FFI + Rust SDK +/// - **Web**: Uses pure Dart WebSocket implementation (no Rust required) +/// +/// Example usage: +/// +/// ```dart +/// // Initialize the client +/// await ConvexClient.initialize( +/// ConvexConfig( +/// deploymentUrl: "https://my-app.convex.cloud", +/// clientId: "flutter-app-1.0", +/// ), +/// ); +/// +/// // Execute a query +/// final result = await ConvexClient.instance.query( +/// "messages:list", +/// {"limit": "10"} +/// ); +/// +/// // Subscribe to real-time updates +/// final subscription = await ConvexClient.instance.subscribe( +/// name: "messages:list", +/// args: {}, +/// onUpdate: (value) { +/// print("New messages: $value"); +/// }, +/// onError: (message, value) { +/// print("Error: $message"); +/// } +/// ); +/// +/// // Execute a mutation +/// await ConvexClient.instance.mutation( +/// name: "messages:send", +/// args: { +/// "body": "Hello!", +/// "author": "User123" +/// } +/// ); +/// +/// // Cancel subscription when done +/// subscription.cancel(); +/// ``` +class ConvexClient { + /// Private static instance for singleton pattern + static ConvexClient? _instance; + + /// The platform-specific implementation (Native or Web) + final IConvexClient _impl; + + /// Private constructor + ConvexClient._(this._impl); + + /// Public getter to access singleton instance + /// Throws StateError if accessed before initialization + static ConvexClient get instance { + if (_instance == null) { + throw StateError( + 'ConvexClient not initialized. ' + 'Call ConvexClient.initialize() first.', + ); + } + return _instance!; + } + + /// Initializes the ConvexClient singleton instance with configuration. + /// + /// This method must be called once before accessing [instance]. + /// Subsequent calls will throw a StateError. + /// + /// The client automatically selects the appropriate platform implementation: + /// - **Web**: Pure Dart WebSocket (no Rust required) + /// - **Mobile/Desktop**: FFI + Rust SDK (requires Rust toolchain for building) + /// + /// Example usage: + /// ```dart + /// await ConvexClient.initialize( + /// ConvexConfig( + /// deploymentUrl: "https://your-app.convex.cloud", + /// clientId: "flutter-app", + /// operationTimeout: Duration(seconds: 30), + /// ), + /// ); + /// ``` + static Future initialize(ConvexConfig config) async { + if (_instance != null) { + throw StateError('ConvexClient already initialized'); + } + + // Create platform-specific implementation using factory + // Factory automatically selects: + // - WebConvexClient (pure Dart) on web + // - NativeConvexClient (FFI + Rust SDK) on native platforms + final IConvexClient impl = await createPlatformClient(config); + + // Create singleton with chosen implementation + _instance = ConvexClient._(impl); + } + + /// Initializes the ConvexClient singleton instance (DEPRECATED). + /// + /// This method is deprecated. Use [initialize] with [ConvexConfig] instead. + /// + /// Example migration: + /// ```dart + /// // Old way (deprecated) + /// await ConvexClient.init(deploymentUrl: "...", clientId: "..."); + /// + /// // New way + /// await ConvexClient.initialize( + /// ConvexConfig(deploymentUrl: "...", clientId: "..."), + /// ); + /// ``` + @Deprecated('Use initialize(ConvexConfig) instead') + static Future init({ + required String deploymentUrl, + required String clientId, + }) async { + if (_instance == null) { + await initialize( + ConvexConfig(deploymentUrl: deploymentUrl, clientId: clientId), + ); + } + return _instance!; + } + + // ============================================================================ + // Public API - All methods delegate to platform-specific implementation + // ============================================================================ + + /// Configuration for this client instance + ConvexConfig get config => _impl.config; + + /// Executes a Convex query operation with timeout. + /// + /// [name] - Name of the query function to execute (e.g., "messages:list") + /// [args] - Map of arguments to pass to the query + /// + /// Returns the query result as a JSON string. + /// Throws [TimeoutException] if the operation exceeds [config.operationTimeout]. + Future query(String name, Map args) => + _impl.query(name, args); + + /// Executes a Convex mutation operation with timeout. + /// + /// [name] - Name of the mutation function to execute + /// [args] - Map of arguments to pass to the mutation + /// + /// Returns the mutation result as a JSON string. + /// Throws [TimeoutException] if the operation exceeds [config.operationTimeout]. + Future mutation({ + required String name, + required Map args, + }) => _impl.mutation(name: name, args: args); + + /// Executes a Convex action operation with timeout. + /// + /// [name] - Name of the action function to execute + /// [args] - Map of arguments to pass to the action + /// + /// Returns the action result as a JSON string. + /// Throws [TimeoutException] if the operation exceeds [config.operationTimeout]. + Future action({ + required String name, + required Map args, + }) => _impl.action(name: name, args: args); + + /// Creates a real-time subscription to a Convex query. + /// + /// [name] - Name of the query function to subscribe to + /// [args] - Map of arguments for the subscription + /// [onUpdate] - Callback function called when new data arrives + /// [onError] - Callback function called when an error occurs + /// + /// Returns a handle that can be used to cancel the subscription. + Future subscribe({ + required String name, + required Map args, + required void Function(String) onUpdate, + required void Function(String, String?) onError, + }) => _impl.subscribe( + name: name, + args: args, + onUpdate: onUpdate, + onError: onError, + ); + + // ============================================================================ + // Authentication API + // ============================================================================ + + /// Sets the authentication token for the client (simple/static). + /// + /// Use this for simple auth scenarios where you manage token refresh externally. + /// For automatic token refresh, use [setAuthWithRefresh] instead. + /// + /// [token] - The authentication token to set, or null to clear auth. + /// + /// Example usage: + /// ```dart + /// // Set auth with a token + /// await client.setAuth(token: 'eyJhbGciOiJSUzI1NiIs...'); + /// + /// // Clear auth + /// await client.setAuth(token: null); + /// ``` + Future setAuth({required String? token}) => _impl.setAuth(token: token); + + /// Sets up authentication with automatic token refresh. + /// + /// This is the recommended way to handle authentication. The [fetchToken] + /// callback will be called: + /// - Immediately to get the initial token + /// - Automatically when the token is about to expire (60 seconds before) + /// + /// Example usage: + /// ```dart + /// final authHandle = await client.setAuthWithRefresh( + /// fetchToken: () async { + /// // Get token from your auth provider (Clerk, Auth0, Firebase, etc.) + /// return await FirebaseAuth.instance.currentUser?.getIdToken(); + /// }, + /// onAuthChange: (isAuthenticated) { + /// print('Auth state changed: $isAuthenticated'); + /// }, + /// ); + /// + /// // Later, when signing out: + /// authHandle.dispose(); + /// ``` + /// + /// [fetchToken] - Async function that returns a JWT token, or null to sign out. + /// [onAuthChange] - Optional callback invoked when auth state changes. + /// + /// Returns an [AuthHandleWrapper] that can be used to dispose the auth session. + Future setAuthWithRefresh({ + required TokenFetcher fetchToken, + AuthStateCallback? onAuthChange, + }) async { + final handle = await _impl.setAuthWithRefresh( + tokenFetcher: fetchToken, + onAuthChange: onAuthChange, + ); + return AuthHandleWrapper._(handle); + } + + /// Clears authentication and disposes any active auth refresh loop. + /// + /// This will: + /// - Stop any running token refresh loop + /// - Clear the auth token from the Convex client + /// - Emit `false` on the [authState] stream + Future clearAuth() => _impl.clearAuth(); + + /// Stream of authentication state changes. + /// Emits `true` when authenticated, `false` when not. + /// + /// Example usage: + /// ```dart + /// ConvexClient.instance.authState.listen((isAuthenticated) { + /// setState(() => _isLoggedIn = isAuthenticated); + /// }); + /// ``` + Stream get authState => _impl.authState; + + /// Current authentication state (synchronous). + /// Returns `true` if authenticated via [setAuthWithRefresh], `false` otherwise. + bool get isAuthenticated => _impl.isAuthenticated; + + // ============================================================================ + // Connection Management API + // ============================================================================ + + /// Stream of WebSocket connection state changes. + /// + /// Emits state whenever the underlying WebSocket connection changes + /// between Connected and Connecting states. This provides real-time + /// connection monitoring without manual polling. + /// + /// Example usage: + /// ```dart + /// ConvexClient.instance.connectionState.listen((state) { + /// if (state == WebSocketConnectionState.connected) { + /// print('Connected to Convex!'); + /// } + /// }); + /// ``` + Stream get connectionState => _impl.connectionState; + + /// Current WebSocket connection state (synchronous). + /// Returns the most recent state from the WebSocket connection. + WebSocketConnectionState get currentConnectionState => + _impl.currentConnectionState; + + /// Convenience getter - returns true if WebSocket is currently connected. + bool get isConnected => _impl.isConnected; + + /// Manually checks the connection status to the Convex backend. + /// + /// **DEPRECATED:** Use the [connectionState] stream for real-time state tracking. + /// This method is slower and less accurate than the WebSocket state stream. + /// + /// This method uses the [ConvexConfig.healthCheckQuery] to verify connectivity. + /// If no health check query is configured, throws a [StateError]. + /// + /// Returns [ConnectionStatus.connected] if the connection is working, + /// [ConnectionStatus.timeout] if the check times out, or + /// [ConnectionStatus.error] if an error occurs. + /// + /// Example usage (deprecated): + /// ```dart + /// final status = await ConvexClient.instance.checkConnection(); + /// if (status == ConnectionStatus.connected) { + /// print('Connected!'); + /// } + /// ``` + /// + /// Recommended alternative - use the real-time connection state stream: + /// ```dart + /// ConvexClient.instance.connectionState.listen((state) { + /// if (state == WebSocketConnectionState.connected) { + /// print('Connected!'); + /// } + /// }); + /// ``` + @Deprecated('Use connectionState stream for real-time connection monitoring') + Future checkConnection() => _impl.checkConnection(); + + /// Attempts to reconnect to the Convex backend. + /// + /// This method restarts the native WebSocket and returns true after the + /// connection-state stream observes the complete reconnect transition. + /// + /// Typically called after the app resumes from background or + /// after detecting a network interruption. + /// + /// Example usage: + /// ```dart + /// ConvexClient.instance.lifecycleEvents.listen((event) { + /// if (event == AppLifecycleEvent.resumed) { + /// final connected = await ConvexClient.instance.reconnect(); + /// if (connected) { + /// print('Reconnected successfully'); + /// } + /// } + /// }); + /// ``` + Future reconnect() => _impl.reconnect(); + + // ============================================================================ + // Lifecycle Management API + // ============================================================================ + + /// Stream of app lifecycle events (foreground/background transitions). + /// + /// Emits events when the app transitions between foreground/background states. + /// Useful for handling reconnection or other lifecycle-based logic. + /// + /// Example usage: + /// ```dart + /// ConvexClient.instance.lifecycleEvents.listen((event) { + /// if (event == AppLifecycleEvent.resumed) { + /// // App came to foreground + /// ConvexClient.instance.reconnect(); + /// } + /// }); + /// ``` + Stream get lifecycleEvents => _impl.lifecycleEvents; + + // ============================================================================ + // Resource Management + // ============================================================================ + + /// Dispose the client and clean up resources. + /// + /// Call this when you're done using the client to free up resources. + /// Note: This is typically not needed as the client is a singleton, + /// but can be useful in testing scenarios. + void dispose() => _impl.dispose(); +} + +/// Wrapper for auth handle providing Dart-friendly API. +/// +/// Returned by [ConvexClient.setAuthWithRefresh] to control the auth session. +class AuthHandleWrapper { + final AuthHandle _handle; + + AuthHandleWrapper._(this._handle); + + /// Whether the user is currently authenticated. + bool get isAuthenticated => _handle.isAuthenticated(); + + /// Dispose the auth session, stopping token refresh and clearing auth. + /// + /// Call this when signing out or when you no longer need automatic token refresh. + void dispose() => _handle.dispose(); +} diff --git a/third_party/convex_flutter/lib/src/convex_config.dart b/third_party/convex_flutter/lib/src/convex_config.dart new file mode 100644 index 00000000..d48f3751 --- /dev/null +++ b/third_party/convex_flutter/lib/src/convex_config.dart @@ -0,0 +1,53 @@ +/// Configuration for ConvexClient initialization. +/// +/// This class holds all configuration options for initializing +/// the Convex client singleton. +/// +/// Example usage: +/// ```dart +/// await ConvexClient.initialize( +/// ConvexConfig( +/// deploymentUrl: "https://your-app.convex.cloud", +/// clientId: "flutter-app", +/// operationTimeout: Duration(seconds: 30), +/// healthCheckQuery: "system:ping", +/// ), +/// ); +/// ``` +class ConvexConfig { + /// The URL of your Convex deployment. + /// + /// Example: "https://my-app.convex.cloud" + final String deploymentUrl; + + /// Optional unique identifier for this client instance. + /// + /// If not provided, defaults to 'flutter-client'. + final String? clientId; + + /// Timeout duration for all query, mutation, and action operations. + /// + /// Operations that take longer than this duration will throw + /// a TimeoutException. Defaults to 30 seconds. + final Duration operationTimeout; + + /// Optional query name to use for manual connection health checks. + /// + /// This should be the name of a lightweight query in your Convex backend + /// that can be used to verify the connection is working. + /// + /// Example: "system:ping" or any query that returns quickly. + /// + /// If null, calling `ConvexClient.instance.checkConnection()` will throw + /// a StateError. You can still check connection by attempting regular + /// queries and catching TimeoutException. + final String? healthCheckQuery; + + /// Creates a new ConvexConfig with the specified options. + const ConvexConfig({ + required this.deploymentUrl, + this.clientId, + this.operationTimeout = const Duration(seconds: 30), + this.healthCheckQuery, + }); +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_factory.dart b/third_party/convex_flutter/lib/src/impl/convex_client_factory.dart new file mode 100644 index 00000000..04e7ea12 --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_factory.dart @@ -0,0 +1,23 @@ +/// Factory for creating platform-specific ConvexClient implementations. +/// +/// Uses conditional imports to avoid compiling web-only code on native platforms. +library convex_client_factory; + +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; + +// Import appropriate implementation based on platform +import 'convex_client_factory_io.dart' + if (dart.library.js_interop) 'convex_client_factory_web.dart'; + +/// Creates the appropriate platform-specific ConvexClient implementation. +/// +/// This factory method uses conditional imports to: +/// - Return NativeConvexClient on native platforms (iOS, Android, macOS, Windows, Linux) +/// - Return WebConvexClient on web platform +/// +/// This prevents web-only libraries (dart:js_interop, package:web) from being +/// compiled into native builds, which would cause compilation errors. +Future createPlatformClient(ConvexConfig config) async { + return await createClientImpl(config); +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_factory_io.dart b/third_party/convex_flutter/lib/src/impl/convex_client_factory_io.dart new file mode 100644 index 00000000..57252d56 --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_factory_io.dart @@ -0,0 +1,12 @@ +/// Native platform (IO) implementation factory. +/// +/// This file is imported on iOS, Android, macOS, Windows, and Linux platforms. + +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; +import 'package:convex_flutter/src/impl/convex_client_native.dart'; + +/// Creates a NativeConvexClient for native platforms. +Future createClientImpl(ConvexConfig config) async { + return await NativeConvexClient.create(config); +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_factory_web.dart b/third_party/convex_flutter/lib/src/impl/convex_client_factory_web.dart new file mode 100644 index 00000000..58d51411 --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_factory_web.dart @@ -0,0 +1,12 @@ +/// Web platform implementation factory. +/// +/// This file is imported only on web platform (when dart:js_interop is available). + +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; +import 'package:convex_flutter/src/impl/convex_client_web.dart'; + +/// Creates a WebConvexClient for web platform. +Future createClientImpl(ConvexConfig config) async { + return await WebConvexClient.create(config); +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_interface.dart b/third_party/convex_flutter/lib/src/impl/convex_client_interface.dart new file mode 100644 index 00000000..1b1bed41 --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_interface.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:convex_flutter/src/rust/lib.dart' show WebSocketConnectionState, SubscriptionHandle, AuthHandle; +import 'package:convex_flutter/src/connection_status.dart'; +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/app_lifecycle_event.dart'; + +/// Abstract interface for platform-specific Convex client implementations. +/// +/// This interface defines the contract that both native (FFI) and web (pure Dart) +/// implementations must follow, ensuring API consistency across all platforms. +/// +/// Implementations: +/// - [NativeConvexClient]: Uses Flutter Rust Bridge (FFI) to call Convex Rust SDK +/// - [WebConvexClient]: Uses pure Dart WebSocket for web platform +abstract class IConvexClient { + /// Configuration for this client instance + ConvexConfig get config; + + // ============================================================================ + // Core Operations + // ============================================================================ + + /// Executes a Convex query operation. + /// + /// [name] - Name of the query function to execute (e.g., "messages:list") + /// [args] - Map of arguments to pass to the query + /// + /// Returns the query result as a JSON string. + /// + /// Throws: + /// - [TimeoutException] if operation exceeds configured timeout + /// - [ClientError] for Convex-specific errors + Future query(String name, Map args); + + /// Executes a Convex mutation operation. + /// + /// [name] - Name of the mutation function to execute + /// [args] - Map of arguments to pass to the mutation + /// + /// Returns the mutation result as a JSON string. + /// + /// Throws: + /// - [TimeoutException] if operation exceeds configured timeout + /// - [ClientError] for Convex-specific errors + Future mutation({ + required String name, + required Map args, + }); + + /// Executes a Convex action operation. + /// + /// [name] - Name of the action function to execute + /// [args] - Map of arguments to pass to the action + /// + /// Returns the action result as a JSON string. + /// + /// Throws: + /// - [TimeoutException] if operation exceeds configured timeout + /// - [ClientError] for Convex-specific errors + Future action({ + required String name, + required Map args, + }); + + /// Creates a real-time subscription to a Convex query. + /// + /// [name] - Name of the query function to subscribe to + /// [args] - Map of arguments for the subscription + /// [onUpdate] - Callback function called when new data arrives + /// [onError] - Callback function called when an error occurs + /// + /// Returns a handle that can be used to cancel the subscription. + Future subscribe({ + required String name, + required Map args, + required void Function(String) onUpdate, + required void Function(String, String?) onError, + }); + + // ============================================================================ + // Authentication + // ============================================================================ + + /// Sets the authentication token for the client. + /// + /// [token] - The JWT authentication token to set, or null to clear + /// + /// Used to authenticate requests to the Convex backend. + Future setAuth({required String? token}); + + /// Sets authentication with automatic token refresh. + /// + /// [tokenFetcher] - Function that returns a fresh JWT token when called + /// [onAuthChange] - Optional callback for auth state changes + /// + /// Returns an [AuthHandle] that manages the auth session and token refresh. + Future setAuthWithRefresh({ + required Future Function() tokenFetcher, + void Function(bool isAuthenticated)? onAuthChange, + }); + + /// Clears the authentication token and stops any active token refresh. + Future clearAuth(); + + /// Stream of authentication state changes. + /// + /// Emits `true` when authenticated, `false` when not authenticated. + Stream get authState; + + /// Returns whether the user is currently authenticated. + bool get isAuthenticated; + + // ============================================================================ + // Connection Management + // ============================================================================ + + /// Stream of WebSocket connection state changes. + /// + /// Emits [WebSocketConnectionState.connected] when connection is established, + /// [WebSocketConnectionState.connecting] when connecting or reconnecting. + /// + /// This is the recommended way to monitor connection status. + Stream get connectionState; + + /// Returns the current WebSocket connection state (synchronous). + WebSocketConnectionState get currentConnectionState; + + /// Returns whether the WebSocket is currently connected. + bool get isConnected; + + /// Manually checks connection status using a health check query. + /// + /// **Deprecated**: Use [connectionState] stream instead for real-time monitoring. + /// + /// Returns [ConnectionStatus] indicating connection state. + @Deprecated('Use connectionState stream instead') + Future checkConnection(); + + /// Manually triggers a reconnection attempt. + /// + /// Returns `true` if reconnection was successful, `false` otherwise. + Future reconnect(); + + // ============================================================================ + // Lifecycle Management + // ============================================================================ + + /// Stream of app lifecycle events (foreground/background transitions). + /// + /// Useful for managing connections when app state changes. + Stream get lifecycleEvents; + + // ============================================================================ + // Resource Management + // ============================================================================ + + /// Disposes of client resources and closes connections. + /// + /// Should be called when the client is no longer needed. + void dispose(); +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_native.dart b/third_party/convex_flutter/lib/src/impl/convex_client_native.dart new file mode 100644 index 00000000..5ac70dbc --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_native.dart @@ -0,0 +1,307 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; +import 'package:convex_flutter/src/rust/lib.dart'; +import 'package:convex_flutter/src/rust/frb_generated.dart'; +import 'package:convex_flutter/src/utils.dart'; +import 'package:convex_flutter/src/connection_status.dart'; +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/app_lifecycle_event.dart'; +import 'package:convex_flutter/src/app_lifecycle_observer.dart'; + +/// Native (FFI-based) implementation of Convex client. +/// +/// This implementation uses Flutter Rust Bridge to call into the official +/// Convex Rust SDK for mobile and desktop platforms (Android, iOS, macOS, +/// Windows, Linux). +/// +/// For web platform, use [WebConvexClient] instead. +class NativeConvexClient implements IConvexClient { + /// The underlying Rust FFI client + final MobileConvexClient _rustClient; + + /// Configuration for this client + @override + final ConvexConfig config; + + /// Stream controller for auth state changes + final StreamController _authStateController = + StreamController.broadcast(); + + /// Stream controller for lifecycle events + final StreamController _lifecycleController = + StreamController.broadcast(); + + /// Stream controller for WebSocket connection state changes + final StreamController _connectionStateController = + StreamController.broadcast(); + + /// Current connection state (cached for sync access) + WebSocketConnectionState _currentConnectionState = + WebSocketConnectionState.connecting; + + /// Current auth handle (if using refresh-based auth) + AuthHandle? _currentAuthHandle; + + /// Lifecycle observer for app state changes + late final AppLifecycleObserver _lifecycleObserver; + + /// Private constructor + NativeConvexClient._(this._rustClient, this.config); + + /// Factory method to create and initialize a native client. + /// + /// This handles: + /// - Rust FFI library initialization + /// - WebSocket state listener setup + /// - Lifecycle observer setup + static Future create(ConvexConfig config) async { + // Initialize Rust FFI library + await RustLib.init(); + + // Create Rust client instance + final rustClient = MobileConvexClient( + deploymentUrl: config.deploymentUrl, + clientId: config.clientId ?? 'flutter-client', + ); + + // Create native client wrapper + final client = NativeConvexClient._(rustClient, config); + + // Setup connection state listener BEFORE any operations + // This prevents race conditions where state changes are missed + await client._setupConnectionStateListener(); + + // Setup lifecycle observer + client._lifecycleObserver = AppLifecycleObserver( + onLifecycleChange: (event) { + if (!client._lifecycleController.isClosed) { + client._lifecycleController.add(event); + } + }, + ); + + return client; + } + + /// Sets up the WebSocket connection state listener. + /// + /// This must be called before any queries/mutations to capture all state changes. + Future _setupConnectionStateListener() async { + debugPrint( + '=== [NativeConvexClient] Setting up WebSocket state listener ===', + ); + debugPrint( + '=== [NativeConvexClient] Current state: ${_currentConnectionState.name} ===', + ); + + try { + await _rustClient.onWebsocketStateChange( + onStateChange: (state) async { + debugPrint( + '=== [NativeConvexClient] State changed: ${state.name} ===', + ); + _currentConnectionState = state; + if (!_connectionStateController.isClosed) { + _connectionStateController.add(state); + } + debugPrint('=== [NativeConvexClient] Stream emission complete ==='); + }, + ); + debugPrint( + '=== [NativeConvexClient] Listener registered successfully ===', + ); + } catch (e) { + debugPrint('ERROR: [NativeConvexClient] Listener setup failed: $e'); + rethrow; + } + } + + // ============================================================================ + // IConvexClient Implementation - Core Operations + // ============================================================================ + + @override + Future query(String name, Map args) async { + final formattedArgs = buildArgs(args); + return await _rustClient + .query(name: name, args: formattedArgs) + .timeout(config.operationTimeout); + } + + @override + Future mutation({ + required String name, + required Map args, + }) async { + final formattedArgs = buildArgs(args); + return await _rustClient + .mutation(name: name, args: formattedArgs) + .timeout(config.operationTimeout); + } + + @override + Future action({ + required String name, + required Map args, + }) async { + final formattedArgs = buildArgs(args); + return await _rustClient + .action(name: name, args: formattedArgs) + .timeout(config.operationTimeout); + } + + @override + Future subscribe({ + required String name, + required Map args, + required void Function(String) onUpdate, + required void Function(String, String?) onError, + }) async { + final formattedArgs = buildArgs(args); + return await _rustClient.subscribe( + name: name, + args: formattedArgs, + onUpdate: (value) => onUpdate(value), + onError: (message, value) => onError(message, value), + ); + } + + // ============================================================================ + // IConvexClient Implementation - Authentication + // ============================================================================ + + @override + Future setAuth({required String? token}) async { + // Clear any existing refresh-based auth + _currentAuthHandle?.dispose(); + _currentAuthHandle = null; + + await _rustClient.setAuth(token: token); + if (!_authStateController.isClosed) { + _authStateController.add(token != null); + } + } + + @override + Future setAuthWithRefresh({ + required Future Function() tokenFetcher, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + // Dispose any existing auth handle + _currentAuthHandle?.dispose(); + + final handle = await _rustClient.setAuthWithRefresh( + fetchToken: () async => await tokenFetcher(), + onAuthChange: (bool isAuth) async { + onAuthChange?.call(isAuth); + if (!_authStateController.isClosed) { + _authStateController.add(isAuth); + } + }, + ); + + _currentAuthHandle = handle; + return handle; + } + + @override + Future clearAuth() async { + _currentAuthHandle?.dispose(); + _currentAuthHandle = null; + await _rustClient.setAuth(token: null); + if (!_authStateController.isClosed) { + _authStateController.add(false); + } + } + + @override + Stream get authState => _authStateController.stream; + + @override + bool get isAuthenticated => _currentAuthHandle?.isAuthenticated() ?? false; + + // ============================================================================ + // IConvexClient Implementation - Connection Management + // ============================================================================ + + @override + Stream get connectionState => + _connectionStateController.stream; + + @override + WebSocketConnectionState get currentConnectionState => + _currentConnectionState; + + @override + bool get isConnected => + _currentConnectionState == WebSocketConnectionState.connected; + + @override + @Deprecated('Use connectionState stream for real-time monitoring') + Future checkConnection() async { + if (config.healthCheckQuery == null) { + throw StateError( + 'No health check query configured. ' + 'Set healthCheckQuery in ConvexConfig or use a real query.', + ); + } + + try { + await _rustClient + .query(name: config.healthCheckQuery!, args: {}) + .timeout(config.operationTimeout); + return ConnectionStatus.connected; + } on TimeoutException { + return ConnectionStatus.timeout; + } catch (e) { + return ConnectionStatus.error; + } + } + + @override + Future reconnect() async { + final states = StreamIterator(connectionState); + var sawConnecting = false; + final deadline = DateTime.now().add(config.operationTimeout); + try { + await _rustClient.reconnectNow(reason: 'convex_flutter:manual'); + while (DateTime.now().isBefore(deadline)) { + final remaining = deadline.difference(DateTime.now()); + if (!await states.moveNext().timeout(remaining)) return false; + if (states.current == WebSocketConnectionState.connecting) { + sawConnecting = true; + } else if (sawConnecting && + states.current == WebSocketConnectionState.connected) { + return true; + } + } + return false; + } on TimeoutException { + return false; + } finally { + await states.cancel(); + } + } + + // ============================================================================ + // IConvexClient Implementation - Lifecycle Management + // ============================================================================ + + @override + Stream get lifecycleEvents => _lifecycleController.stream; + + // ============================================================================ + // IConvexClient Implementation - Resource Management + // ============================================================================ + + @override + void dispose() { + _currentAuthHandle?.dispose(); + _lifecycleObserver.dispose(); + _authStateController.close(); + _lifecycleController.close(); + _connectionStateController.close(); + } +} diff --git a/third_party/convex_flutter/lib/src/impl/convex_client_web.dart b/third_party/convex_flutter/lib/src/impl/convex_client_web.dart new file mode 100644 index 00000000..870eee2d --- /dev/null +++ b/third_party/convex_flutter/lib/src/impl/convex_client_web.dart @@ -0,0 +1,875 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; +import 'package:convex_flutter/src/impl/convex_client_interface.dart'; +import 'package:convex_flutter/src/rust/lib.dart' show WebSocketConnectionState, SubscriptionHandle, AuthHandle; +import 'package:convex_flutter/src/connection_status.dart'; +import 'package:convex_flutter/src/convex_config.dart'; +import 'package:convex_flutter/src/app_lifecycle_event.dart'; +import 'package:convex_flutter/src/app_lifecycle_observer.dart'; + +/// Web (pure Dart) implementation of Convex client. +/// +/// This implementation uses the browser's native WebSocket API for web platform, +/// avoiding the need for Rust toolchain or FFI. It implements the same +/// [IConvexClient] interface as [NativeConvexClient], ensuring API compatibility +/// across all platforms. +/// +/// For mobile/desktop platforms, use [NativeConvexClient] instead. +class WebConvexClient implements IConvexClient { + /// Configuration for this client + @override + final ConvexConfig config; + + /// WebSocket connection to Convex backend + web.WebSocket? _ws; + + /// Stream controller for auth state changes + final StreamController _authStateController = + StreamController.broadcast(); + + /// Stream controller for lifecycle events + final StreamController _lifecycleController = + StreamController.broadcast(); + + /// Stream controller for WebSocket connection state changes + final StreamController _connectionStateController = + StreamController.broadcast(); + + /// Current connection state (cached for sync access) + WebSocketConnectionState _currentConnectionState = + WebSocketConnectionState.connecting; + + /// Current auth token + String? _currentAuthToken; + + /// Lifecycle observer for app state changes + late final AppLifecycleObserver _lifecycleObserver; + + /// Message ID counter for generating unique request IDs + int _messageIdCounter = 0; + + /// Session ID for Convex sync protocol + String? _sessionId; + + /// Query ID counter for subscriptions + int _queryIdCounter = 0; + + /// Query set version counter for ModifyQuerySet messages + int _querySetVersion = 0; + + /// Pending requests waiting for responses (query, mutation, action) + final Map> _pendingRequests = {}; + + /// Active subscriptions + final Map _subscriptions = {}; + + /// Reconnection attempt counter + int _reconnectAttempts = 0; + + /// Maximum reconnection attempts + static const int _maxReconnectAttempts = 10; + + /// Base reconnection delay + static const Duration _baseReconnectDelay = Duration(seconds: 1); + + /// Timer for reconnection + Timer? _reconnectTimer; + + /// Whether client is disposed + bool _isDisposed = false; + + /// Private constructor + WebConvexClient._(this.config); + + /// Factory method to create and initialize a web client. + /// + /// This handles: + /// - WebSocket connection setup + /// - Event listener registration + /// - Lifecycle observer setup + static Future create(ConvexConfig config) async { + debugPrint('=== [WebConvexClient] Creating web client ==='); + + final client = WebConvexClient._(config); + + // Setup lifecycle observer + // Note: On web, we don't reconnect on lifecycle events because: + // 1. Page navigation triggers lifecycle events but doesn't disconnect WebSocket + // 2. WebSocket onclose handler already manages reconnection + // 3. Browser tab visibility changes are the only real "background" events + client._lifecycleObserver = AppLifecycleObserver( + onLifecycleChange: (event) { + client._lifecycleController.add(event); + // Do NOT trigger reconnection on web - let WebSocket manage itself + debugPrint('=== [WebConvexClient] Lifecycle event: ${event.name} (no action on web) ==='); + }, + ); + + // Establish WebSocket connection + await client._connect(); + + debugPrint('=== [WebConvexClient] Client created successfully ==='); + return client; + } + + /// Establishes WebSocket connection to Convex backend. + Future _connect() async { + if (_isDisposed) return; + + debugPrint('=== [WebConvexClient] Connecting to Convex ==='); + + try { + // Convert HTTPS to WSS URL with correct Convex sync endpoint + // Format: wss://deployment.convex.cloud/api/{version}/sync + final wsUrl = config.deploymentUrl.replaceFirst('https', 'wss'); + final fullUrl = '$wsUrl/api/sync'; + + debugPrint('=== [WebConvexClient] WebSocket URL: $fullUrl ==='); + + // Update state to connecting + _updateConnectionState(WebSocketConnectionState.connecting); + + // Create WebSocket connection + _ws = web.WebSocket(fullUrl); + + // Setup event listeners + _setupWebSocketListeners(); + + debugPrint('=== [WebConvexClient] WebSocket connection initiated ==='); + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Connection failed: $e'); + _scheduleReconnect(); + } + } + + /// Sets up WebSocket event listeners. + void _setupWebSocketListeners() { + final ws = _ws; + if (ws == null) return; + + // Connection opened + ws.onopen = (web.Event event) { + debugPrint('=== [WebConvexClient] WebSocket opened ==='); + _reconnectAttempts = 0; // Reset reconnection counter + _querySetVersion = 0; // Reset query set version for new connection + _updateConnectionState(WebSocketConnectionState.connected); + + // Send Connect handshake (required by Convex protocol) + _sendConnectMessage(); + + // Send auth token if available + if (_currentAuthToken != null) { + _sendAuthMessage(_currentAuthToken!); + } + }.toJS; + + // Connection closed + ws.onclose = (web.CloseEvent event) { + final code = event.code; + final reason = event.reason; + final wasClean = event.wasClean; + debugPrint('=== [WebConvexClient] WebSocket closed ==='); + debugPrint('=== [WebConvexClient] Close code: $code, reason: "$reason", wasClean: $wasClean ==='); + _updateConnectionState(WebSocketConnectionState.connecting); + + // Attempt reconnection if not disposed + if (!_isDisposed) { + _scheduleReconnect(); + } + }.toJS; + + // Connection error + ws.onerror = (web.Event event) { + debugPrint('ERROR: [WebConvexClient] WebSocket error occurred'); + debugPrint('ERROR: [WebConvexClient] Event type: ${event.type}'); + _updateConnectionState(WebSocketConnectionState.connecting); + }.toJS; + + // Message received + ws.onmessage = (web.MessageEvent event) { + final data = event.data; + + // Convert JSAny? to String + final dataString = (data as JSString?)?.toDart; + if (dataString != null) { + _handleMessage(dataString); + } else { + debugPrint('WARNING: [WebConvexClient] Received non-string message'); + } + }.toJS; + } + + /// Handles incoming WebSocket messages. + void _handleMessage(String data) { + try { + debugPrint('=== [WebConvexClient] RAW MESSAGE: $data ==='); + + final message = jsonDecode(data) as Map; + final type = message['type'] as String?; + final id = message['id'] as String?; + + debugPrint('=== [WebConvexClient] Received message type: $type, id: $id ==='); + + switch (type) { + case 'Transition': + // Query subscription updates + _handleTransition(message); + break; + + case 'MutationResponse': + _handleMutationResponse(message); + break; + + case 'ActionResponse': + _handleActionResponse(message); + break; + + case 'Ping': + // Respond to server ping + _sendPong(); + break; + + case 'FatalError': + _handleFatalError(message); + break; + + case 'AuthError': + _handleAuthError(message); + break; + + default: + debugPrint('WARNING: [WebConvexClient] Unknown message type: $type'); + } + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Failed to parse message: $e'); + } + } + + /// Handles Transition messages (query subscription updates). + void _handleTransition(Map message) { + final modifications = message['modifications'] as List?; + if (modifications == null) return; + + for (final mod in modifications) { + final queryId = mod['queryId']?.toString(); + if (queryId == null) continue; + + final subscription = _subscriptions[queryId]; + if (subscription == null) continue; + + final value = mod['value']; + if (value != null) { + final valueJson = jsonEncode(value); + subscription.onUpdate(valueJson); + } + } + } + + /// Handles MutationResponse messages. + void _handleMutationResponse(Map message) { + final requestId = message['requestId'] as int?; + if (requestId == null) return; + + final completer = _pendingRequests.remove(requestId); + if (completer == null) return; + + final result = message['result']; + if (result != null) { + final resultJson = jsonEncode(result); + completer.complete(resultJson); + } else { + completer.completeError(Exception('No result in mutation response')); + } + } + + /// Handles ActionResponse messages. + void _handleActionResponse(Map message) { + final requestId = message['requestId'] as int?; + if (requestId == null) return; + + final completer = _pendingRequests.remove(requestId); + if (completer == null) return; + + final result = message['result']; + if (result != null) { + final resultJson = jsonEncode(result); + completer.complete(resultJson); + } else { + completer.completeError(Exception('No result in action response')); + } + } + + /// Handles FatalError messages. + void _handleFatalError(Map message) { + final error = message['error'] as String? ?? 'Unknown fatal error'; + debugPrint('FATAL ERROR: [WebConvexClient] $error'); + + // Close connection on fatal error + _ws?.close(); + } + + /// Handles AuthError messages. + void _handleAuthError(Map message) { + final error = message['error'] as String? ?? 'Authentication error'; + debugPrint('AUTH ERROR: [WebConvexClient] $error'); + + // Clear auth and notify + _authStateController.add(false); + } + + /// Sends Pong response to server Ping. + void _sendPong() { + try { + _sendMessage({ + 'type': 'Event', + 'eventType': 'Pong', // Required field + 'event': null, // Required field (can be null) + }); + debugPrint('=== [WebConvexClient] Sent Pong ==='); + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Failed to send Pong: $e'); + } + } + + /// Sends Connect handshake message. + void _sendConnectMessage() { + try { + // Generate or reuse session ID (must be valid UUID format) + _sessionId ??= _generateUuid(); + + _sendMessage({ + 'type': 'Connect', + 'sessionId': _sessionId, + 'maxObservedTimestamp': null, + 'connectionCount': _reconnectAttempts + 1, + 'lastCloseReason': null, // Required field + 'clientTs': DateTime.now().millisecondsSinceEpoch, // Required field + }); + debugPrint('=== [WebConvexClient] Sent Connect handshake ==='); + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Failed to send Connect: $e'); + } + } + + /// Generates a RFC 4122 compliant UUID v4 string. + String _generateUuid() { + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + // Where 4 = version 4, y = variant bits (8, 9, A, or B) + final random = math.Random(); + + // Generate random values for each segment + final segment1 = random.nextInt(0x100000000); // 32 bits = 8 hex chars + final segment2 = random.nextInt(0x10000); // 16 bits = 4 hex chars + final segment3 = random.nextInt(0x10000); // 16 bits = 4 hex chars (we'll set version) + final segment4 = random.nextInt(0x10000); // 16 bits = 4 hex chars (we'll set variant) + final segment5a = random.nextInt(0x100000000); // 32 bits = 8 hex chars + final segment5b = random.nextInt(0x10000); // 16 bits = 4 hex chars + + // Set version 4 (bits 12-15 of segment3 = 0100) + final version4 = (segment3 & 0x0FFF) | 0x4000; + + // Set variant bits (bits 14-15 of segment4 = 10) + final variant = (segment4 & 0x3FFF) | 0x8000; + + // Combine segment5 parts into 12 hex digits + final segment5 = '${segment5a.toRadixString(16).padLeft(8, '0')}${segment5b.toRadixString(16).padLeft(4, '0')}'; + + return '${segment1.toRadixString(16).padLeft(8, '0')}-' + '${segment2.toRadixString(16).padLeft(4, '0')}-' + '${version4.toRadixString(16).padLeft(4, '0')}-' + '${variant.toRadixString(16).padLeft(4, '0')}-' + '$segment5'; + } + + /// Updates connection state and emits to stream. + void _updateConnectionState(WebSocketConnectionState newState) { + if (_currentConnectionState != newState) { + debugPrint('=== [WebConvexClient] State transition: ${_currentConnectionState.name} → ${newState.name} ==='); + _currentConnectionState = newState; + _connectionStateController.add(newState); + } + } + + /// Schedules a reconnection attempt with exponential backoff. + void _scheduleReconnect() { + if (_isDisposed) return; + + _reconnectTimer?.cancel(); + + if (_reconnectAttempts >= _maxReconnectAttempts) { + debugPrint('ERROR: [WebConvexClient] Max reconnection attempts reached'); + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s (max) + final delay = _baseReconnectDelay * (1 << _reconnectAttempts.clamp(0, 5)); + _reconnectAttempts++; + + debugPrint('=== [WebConvexClient] Scheduling reconnect attempt $_reconnectAttempts in ${delay.inSeconds}s ==='); + + _reconnectTimer = Timer(delay, () { + debugPrint('=== [WebConvexClient] Executing reconnect attempt $_reconnectAttempts ==='); + _connect(); + }); + } + + /// Generates a unique message ID. + int _generateMessageId() { + return _messageIdCounter++; + } + + /// Sends a message over WebSocket. + void _sendMessage(Map message) { + final ws = _ws; + if (ws == null || ws.readyState != web.WebSocket.OPEN) { + throw StateError('WebSocket not connected'); + } + + final messageJson = jsonEncode(message); + debugPrint('=== [WebConvexClient] SENDING: $messageJson ==='); + ws.send(messageJson.toJS); + + debugPrint('=== [WebConvexClient] Sent message: ${message['type']} (id: ${message['id']}) ==='); + } + + /// Sends authentication message. + void _sendAuthMessage(String token) { + try { + // Send Authenticate message (Convex protocol) + _sendMessage({ + 'type': 'Authenticate', + 'token': token, + }); + debugPrint('=== [WebConvexClient] Auth token sent ==='); + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Failed to send auth: $e'); + } + } + + // ============================================================================ + // IConvexClient Implementation - Core Operations + // ============================================================================ + + @override + Future query(String name, Map args) async { + // Queries in Convex protocol use ModifyQuerySet (like subscriptions) + // We subscribe, wait for first result, then unsubscribe + final queryId = _queryIdCounter++; + final queryIdStr = queryId.toString(); + final completer = Completer(); + + // Create temporary subscription for one-shot query + final subscription = _WebSubscription( + id: queryIdStr, + onUpdate: (value) { + if (!completer.isCompleted) { + completer.complete(value); + // Auto-unsubscribe after getting result + _unsubscribe(queryIdStr); + } + }, + onError: (message, value) { + if (!completer.isCompleted) { + completer.completeError(Exception(message)); + _subscriptions.remove(queryIdStr); + } + }, + ); + _subscriptions[queryIdStr] = subscription; + + try { + // Send ModifyQuerySet with Add (Convex protocol for queries) + final baseVersion = _querySetVersion; + final newVersion = ++_querySetVersion; + + _sendMessage({ + 'type': 'ModifyQuerySet', + 'baseVersion': baseVersion, + 'newVersion': newVersion, + 'modifications': [ + { + 'type': 'Add', + 'queryId': queryId, + 'udfPath': name, + 'args': [args], // Args must be array + } + ], + }); + + return await completer.future.timeout( + config.operationTimeout, + onTimeout: () { + _subscriptions.remove(queryIdStr); + throw TimeoutException('Query timeout: $name'); + }, + ); + } catch (e) { + _subscriptions.remove(queryIdStr); + rethrow; + } + } + + @override + Future mutation({ + required String name, + required Map args, + }) async { + final requestId = _generateMessageId(); + final completer = Completer(); + _pendingRequests[requestId] = completer; + + try { + // Send Mutation message (Convex protocol) + _sendMessage({ + 'type': 'Mutation', + 'requestId': requestId, + 'udfPath': name, // Use udfPath instead of name + 'args': [args], // Args must be array, not object + }); + + return await completer.future.timeout( + config.operationTimeout, + onTimeout: () { + _pendingRequests.remove(requestId); + throw TimeoutException('Mutation timeout: $name'); + }, + ); + } catch (e) { + _pendingRequests.remove(requestId); + rethrow; + } + } + + @override + Future action({ + required String name, + required Map args, + }) async { + final requestId = _generateMessageId(); + final completer = Completer(); + _pendingRequests[requestId] = completer; + + try { + // Send Action message (Convex protocol) + _sendMessage({ + 'type': 'Action', + 'requestId': requestId, + 'udfPath': name, // Use udfPath instead of name + 'args': [args], // Args must be array, not object + }); + + return await completer.future.timeout( + config.operationTimeout, + onTimeout: () { + _pendingRequests.remove(requestId); + throw TimeoutException('Action timeout: $name'); + }, + ); + } catch (e) { + _pendingRequests.remove(requestId); + rethrow; + } + } + + @override + Future subscribe({ + required String name, + required Map args, + required void Function(String) onUpdate, + required void Function(String, String?) onError, + }) async { + // Use incrementing query ID (Convex protocol requirement) + final queryId = _queryIdCounter++; + final queryIdStr = queryId.toString(); + + // Create subscription record + final subscription = _WebSubscription( + id: queryIdStr, + onUpdate: onUpdate, + onError: onError, + ); + _subscriptions[queryIdStr] = subscription; + + try { + // Send ModifyQuerySet with Add modification (Convex protocol) + final baseVersion = _querySetVersion; + final newVersion = ++_querySetVersion; + + _sendMessage({ + 'type': 'ModifyQuerySet', + 'baseVersion': baseVersion, + 'newVersion': newVersion, + 'modifications': [ + { + 'type': 'Add', + 'queryId': queryId, + 'udfPath': name, // Use udfPath instead of name + 'args': [args], // Args must be array, not object + } + ], + }); + + debugPrint('=== [WebConvexClient] Subscription created: queryId=$queryId ==='); + + // Return handle for cancellation + return _WebSubscriptionHandle( + onCancel: () { + _unsubscribe(queryIdStr); + }, + ); + } catch (e) { + _subscriptions.remove(queryIdStr); + rethrow; + } + } + + /// Unsubscribes from a subscription. + void _unsubscribe(String queryIdStr) { + final subscription = _subscriptions.remove(queryIdStr); + if (subscription == null) return; + + debugPrint('=== [WebConvexClient] Unsubscribing: queryId=$queryIdStr ==='); + + try { + final queryId = int.tryParse(queryIdStr); + if (queryId == null) return; + + // Send ModifyQuerySet with Remove modification (Convex protocol) + final baseVersion = _querySetVersion; + final newVersion = ++_querySetVersion; + + _sendMessage({ + 'type': 'ModifyQuerySet', + 'baseVersion': baseVersion, + 'newVersion': newVersion, + 'modifications': [ + { + 'type': 'Remove', + 'queryId': queryId, + } + ], + }); + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Failed to send unsubscribe: $e'); + } + } + + // ============================================================================ + // IConvexClient Implementation - Authentication + // ============================================================================ + + @override + Future setAuth({required String? token}) async { + _currentAuthToken = token; + + if (token != null) { + _sendAuthMessage(token); + _authStateController.add(true); + } else { + _sendAuthMessage(''); // Clear auth + _authStateController.add(false); + } + } + + @override + Future setAuthWithRefresh({ + required Future Function() tokenFetcher, + void Function(bool isAuthenticated)? onAuthChange, + }) async { + // TODO: Implement token refresh for web + // For now, just fetch token once and set it + final token = await tokenFetcher(); + await setAuth(token: token); + + if (onAuthChange != null) { + onAuthChange(token != null); + } + + // Return a simple auth handle (no auto-refresh yet) + return _WebAuthHandle( + isAuth: token != null, + onDispose: () async { + await setAuth(token: null); + }, + ); + } + + @override + Future clearAuth() async { + await setAuth(token: null); + } + + @override + Stream get authState => _authStateController.stream; + + @override + bool get isAuthenticated => _currentAuthToken != null; + + // ============================================================================ + // IConvexClient Implementation - Connection Management + // ============================================================================ + + @override + Stream get connectionState => + _connectionStateController.stream; + + @override + WebSocketConnectionState get currentConnectionState => _currentConnectionState; + + @override + bool get isConnected => + _currentConnectionState == WebSocketConnectionState.connected; + + @override + @Deprecated('Use connectionState stream for real-time monitoring') + Future checkConnection() async { + if (config.healthCheckQuery == null) { + throw StateError( + 'No health check query configured. ' + 'Set healthCheckQuery in ConvexConfig or use a real query.', + ); + } + + try { + await query(config.healthCheckQuery!, {}); + return ConnectionStatus.connected; + } on TimeoutException { + return ConnectionStatus.timeout; + } catch (e) { + return ConnectionStatus.error; + } + } + + @override + Future reconnect() async { + debugPrint('=== [WebConvexClient] Manual reconnect requested ==='); + + // Close existing connection if any + _ws?.close(); + _ws = null; + + // Reset reconnection counter for manual reconnect + _reconnectAttempts = 0; + + // Attempt connection + try { + await _connect(); + + // Wait a bit for connection to establish + await Future.delayed(const Duration(seconds: 2)); + + return isConnected; + } catch (e) { + debugPrint('ERROR: [WebConvexClient] Manual reconnect failed: $e'); + return false; + } + } + + // ============================================================================ + // IConvexClient Implementation - Lifecycle Management + // ============================================================================ + + @override + Stream get lifecycleEvents => _lifecycleController.stream; + + // ============================================================================ + // IConvexClient Implementation - Resource Management + // ============================================================================ + + @override + void dispose() { + if (_isDisposed) return; + + debugPrint('=== [WebConvexClient] Disposing client ==='); + _isDisposed = true; + + // Cancel reconnection timer + _reconnectTimer?.cancel(); + + // Close WebSocket + _ws?.close(); + _ws = null; + + // Dispose lifecycle observer + _lifecycleObserver.dispose(); + + // Close streams + _authStateController.close(); + _lifecycleController.close(); + _connectionStateController.close(); + + // Clear pending requests and subscriptions + _pendingRequests.clear(); + _subscriptions.clear(); + + debugPrint('=== [WebConvexClient] Client disposed ==='); + } +} + +/// Internal subscription record for web client. +class _WebSubscription { + final String id; + final void Function(String) onUpdate; + final void Function(String, String?) onError; + + _WebSubscription({ + required this.id, + required this.onUpdate, + required this.onError, + }); +} + +/// Web implementation of SubscriptionHandle. +class _WebSubscriptionHandle implements SubscriptionHandle { + final void Function() onCancel; + bool _isCancelled = false; + + _WebSubscriptionHandle({required this.onCancel}); + + @override + void cancel() { + if (!_isCancelled) { + _isCancelled = true; + onCancel(); + } + } + + @override + void dispose() { + cancel(); + } + + @override + bool get isDisposed => _isCancelled; +} + +/// Web implementation of AuthHandle. +class _WebAuthHandle implements AuthHandle { + final bool isAuth; + final Future Function() onDispose; + bool _isDisposed = false; + + _WebAuthHandle({ + required this.isAuth, + required this.onDispose, + }); + + @override + bool isAuthenticated() => isAuth && !_isDisposed; + + @override + void dispose() { + if (!_isDisposed) { + _isDisposed = true; + onDispose(); + } + } + + @override + bool get isDisposed => _isDisposed; +} diff --git a/third_party/convex_flutter/lib/src/rust/frb_generated.dart b/third_party/convex_flutter/lib/src/rust/frb_generated.dart new file mode 100644 index 00000000..47d8eb5e --- /dev/null +++ b/third_party/convex_flutter/lib/src/rust/frb_generated.dart @@ -0,0 +1,2291 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'frb_generated.io.dart' + if (dart.library.js_interop) 'frb_generated.web.dart'; +import 'lib.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; + +/// Main entrypoint of the Rust API +class RustLib extends BaseEntrypoint { + @internal + static final instance = RustLib._(); + + RustLib._(); + + /// Initialize flutter_rust_bridge + static Future init({ + RustLibApi? api, + BaseHandler? handler, + ExternalLibrary? externalLibrary, + bool forceSameCodegenVersion = true, + }) async { + await instance.initImpl( + api: api, + handler: handler, + externalLibrary: externalLibrary, + forceSameCodegenVersion: forceSameCodegenVersion, + ); + } + + /// Initialize flutter_rust_bridge in mock mode. + /// No libraries for FFI are loaded. + static void initMock({required RustLibApi api}) { + instance.initMockImpl(api: api); + } + + /// Dispose flutter_rust_bridge + /// + /// The call to this function is optional, since flutter_rust_bridge (and everything else) + /// is automatically disposed when the app stops. + static void dispose() => instance.disposeImpl(); + + @override + ApiImplConstructor get apiImplConstructor => + RustLibApiImpl.new; + + @override + WireConstructor get wireConstructor => + RustLibWire.fromExternalLibrary; + + @override + Future executeRustInitializers() async {} + + @override + ExternalLibraryLoaderConfig get defaultExternalLibraryLoaderConfig => + kDefaultExternalLibraryLoaderConfig; + + @override + String get codegenVersion => '2.11.1'; + + @override + int get rustContentHash => -829523767; + + static const kDefaultExternalLibraryLoaderConfig = + ExternalLibraryLoaderConfig( + stem: 'convex_flutter', + ioDirectory: 'rust/target/release/', + webPrefix: 'pkg/', + ); +} + +abstract class RustLibApi extends BaseApi { + void crateAuthHandleDispose({required AuthHandle that}); + + bool crateAuthHandleIsAuthenticated({required AuthHandle that}); + + Future crateCallbackSubscriberDartFnOnError({ + required CallbackSubscriberDartFn that, + required String message, + String? value, + }); + + Future crateCallbackSubscriberDartFnOnUpdate({ + required CallbackSubscriberDartFn that, + required String value, + }); + + Future crateCallbackSubscriberOnError({ + required CallbackSubscriber that, + required String message, + String? value, + }); + + Future crateCallbackSubscriberOnUpdate({ + required CallbackSubscriber that, + required String value, + }); + + Future crateMobileConvexClientAction({ + required MobileConvexClient that, + required String name, + required Map args, + }); + + Future crateMobileConvexClientMutation({ + required MobileConvexClient that, + required String name, + required Map args, + }); + + MobileConvexClient crateMobileConvexClientNew({ + required String deploymentUrl, + required String clientId, + }); + + Future crateMobileConvexClientOnWebsocketStateChange({ + required MobileConvexClient that, + required FutureOr Function(WebSocketConnectionState) onStateChange, + }); + + Future crateMobileConvexClientQuery({ + required MobileConvexClient that, + required String name, + required Map args, + }); + + Future crateMobileConvexClientReconnectNow({ + required MobileConvexClient that, + required String reason, + }); + + Future crateMobileConvexClientSetAuth({ + required MobileConvexClient that, + String? token, + }); + + Future crateMobileConvexClientSetAuthWithRefresh({ + required MobileConvexClient that, + required FutureOr Function() fetchToken, + required FutureOr Function(bool) onAuthChange, + }); + + Future crateMobileConvexClientSubscribe({ + required MobileConvexClient that, + required String name, + required Map args, + required FutureOr Function(String) onUpdate, + required FutureOr Function(String, String?) onError, + }); + + void crateSubscriptionHandleCancel({required SubscriptionHandle that}); + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_AuthHandle; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_AuthHandle; + + CrossPlatformFinalizerArg get rust_arc_decrement_strong_count_AuthHandlePtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_CallbackSubscriber; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_CallbackSubscriber; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberPtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_CallbackSubscriberDartFn; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_CallbackSubscriberDartFn; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberDartFnPtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_MobileConvexClient; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_MobileConvexClient; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_MobileConvexClientPtr; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_SubscriptionHandle; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_SubscriptionHandle; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_SubscriptionHandlePtr; +} + +class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { + RustLibApiImpl({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + @override + void crateAuthHandleDispose({required AuthHandle that}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + that, + serializer, + ); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 1)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateAuthHandleDisposeConstMeta, + argValues: [that], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateAuthHandleDisposeConstMeta => + const TaskConstMeta(debugName: "AuthHandle_dispose", argNames: ["that"]); + + @override + bool crateAuthHandleIsAuthenticated({required AuthHandle that}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + that, + serializer, + ); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 2)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_bool, + decodeErrorData: null, + ), + constMeta: kCrateAuthHandleIsAuthenticatedConstMeta, + argValues: [that], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateAuthHandleIsAuthenticatedConstMeta => + const TaskConstMeta( + debugName: "AuthHandle_is_authenticated", + argNames: ["that"], + ); + + @override + Future crateCallbackSubscriberDartFnOnError({ + required CallbackSubscriberDartFn that, + required String message, + String? value, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + that, + serializer, + ); + sse_encode_String(message, serializer); + sse_encode_opt_String(value, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 3, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateCallbackSubscriberDartFnOnErrorConstMeta, + argValues: [that, message, value], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateCallbackSubscriberDartFnOnErrorConstMeta => + const TaskConstMeta( + debugName: "CallbackSubscriberDartFn_on_error", + argNames: ["that", "message", "value"], + ); + + @override + Future crateCallbackSubscriberDartFnOnUpdate({ + required CallbackSubscriberDartFn that, + required String value, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + that, + serializer, + ); + sse_encode_String(value, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 4, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateCallbackSubscriberDartFnOnUpdateConstMeta, + argValues: [that, value], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateCallbackSubscriberDartFnOnUpdateConstMeta => + const TaskConstMeta( + debugName: "CallbackSubscriberDartFn_on_update", + argNames: ["that", "value"], + ); + + @override + Future crateCallbackSubscriberOnError({ + required CallbackSubscriber that, + required String message, + String? value, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + that, + serializer, + ); + sse_encode_String(message, serializer); + sse_encode_opt_String(value, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 5, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateCallbackSubscriberOnErrorConstMeta, + argValues: [that, message, value], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateCallbackSubscriberOnErrorConstMeta => + const TaskConstMeta( + debugName: "CallbackSubscriber_on_error", + argNames: ["that", "message", "value"], + ); + + @override + Future crateCallbackSubscriberOnUpdate({ + required CallbackSubscriber that, + required String value, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + that, + serializer, + ); + sse_encode_String(value, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 6, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateCallbackSubscriberOnUpdateConstMeta, + argValues: [that, value], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateCallbackSubscriberOnUpdateConstMeta => + const TaskConstMeta( + debugName: "CallbackSubscriber_on_update", + argNames: ["that", "value"], + ); + + @override + Future crateMobileConvexClientAction({ + required MobileConvexClient that, + required String name, + required Map args, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_String(name, serializer); + sse_encode_Map_String_String_None(args, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 7, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientActionConstMeta, + argValues: [that, name, args], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientActionConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_action", + argNames: ["that", "name", "args"], + ); + + @override + Future crateMobileConvexClientMutation({ + required MobileConvexClient that, + required String name, + required Map args, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_String(name, serializer); + sse_encode_Map_String_String_None(args, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 8, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientMutationConstMeta, + argValues: [that, name, args], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientMutationConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_mutation", + argNames: ["that", "name", "args"], + ); + + @override + MobileConvexClient crateMobileConvexClientNew({ + required String deploymentUrl, + required String clientId, + }) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_String(deploymentUrl, serializer); + sse_encode_String(clientId, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 9)!; + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient, + decodeErrorData: null, + ), + constMeta: kCrateMobileConvexClientNewConstMeta, + argValues: [deploymentUrl, clientId], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientNewConstMeta => const TaskConstMeta( + debugName: "MobileConvexClient_new", + argNames: ["deploymentUrl", "clientId"], + ); + + @override + Future crateMobileConvexClientOnWebsocketStateChange({ + required MobileConvexClient that, + required FutureOr Function(WebSocketConnectionState) onStateChange, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + onStateChange, + serializer, + ); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 10, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientOnWebsocketStateChangeConstMeta, + argValues: [that, onStateChange], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientOnWebsocketStateChangeConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_on_websocket_state_change", + argNames: ["that", "onStateChange"], + ); + + @override + Future crateMobileConvexClientQuery({ + required MobileConvexClient that, + required String name, + required Map args, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_String(name, serializer); + sse_encode_Map_String_String_None(args, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 11, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_String, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientQueryConstMeta, + argValues: [that, name, args], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientQueryConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_query", + argNames: ["that", "name", "args"], + ); + + @override + Future crateMobileConvexClientReconnectNow({ + required MobileConvexClient that, + required String reason, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_String(reason, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 12, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientReconnectNowConstMeta, + argValues: [that, reason], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientReconnectNowConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_reconnect_now", + argNames: ["that", "reason"], + ); + + @override + Future crateMobileConvexClientSetAuth({ + required MobileConvexClient that, + String? token, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_opt_String(token, serializer); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 13, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientSetAuthConstMeta, + argValues: [that, token], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientSetAuthConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_set_auth", + argNames: ["that", "token"], + ); + + @override + Future crateMobileConvexClientSetAuthWithRefresh({ + required MobileConvexClient that, + required FutureOr Function() fetchToken, + required FutureOr Function(bool) onAuthChange, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_DartFn_Inputs__Output_opt_String_AnyhowException( + fetchToken, + serializer, + ); + sse_encode_DartFn_Inputs_bool_Output_unit_AnyhowException( + onAuthChange, + serializer, + ); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 14, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientSetAuthWithRefreshConstMeta, + argValues: [that, fetchToken, onAuthChange], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientSetAuthWithRefreshConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_set_auth_with_refresh", + argNames: ["that", "fetchToken", "onAuthChange"], + ); + + @override + Future crateMobileConvexClientSubscribe({ + required MobileConvexClient that, + required String name, + required Map args, + required FutureOr Function(String) onUpdate, + required FutureOr Function(String, String?) onError, + }) { + return handler.executeNormal( + NormalTask( + callFfi: (port_) { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + that, + serializer, + ); + sse_encode_String(name, serializer); + sse_encode_Map_String_String_None(args, serializer); + sse_encode_DartFn_Inputs_String_Output_unit_AnyhowException( + onUpdate, + serializer, + ); + sse_encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + onError, + serializer, + ); + pdeCallFfi( + generalizedFrbRustBinding, + serializer, + funcId: 15, + port: port_, + ); + }, + codec: SseCodec( + decodeSuccessData: + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle, + decodeErrorData: sse_decode_client_error, + ), + constMeta: kCrateMobileConvexClientSubscribeConstMeta, + argValues: [that, name, args, onUpdate, onError], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateMobileConvexClientSubscribeConstMeta => + const TaskConstMeta( + debugName: "MobileConvexClient_subscribe", + argNames: ["that", "name", "args", "onUpdate", "onError"], + ); + + @override + void crateSubscriptionHandleCancel({required SubscriptionHandle that}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + that, + serializer, + ); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 16)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateSubscriptionHandleCancelConstMeta, + argValues: [that], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateSubscriptionHandleCancelConstMeta => + const TaskConstMeta( + debugName: "SubscriptionHandle_cancel", + argNames: ["that"], + ); + + Future Function(int, dynamic) + encode_DartFn_Inputs_String_Output_unit_AnyhowException( + FutureOr Function(String) raw, + ) { + return (callId, rawArg0) async { + final arg0 = dco_decode_String(rawArg0); + + Box? rawOutput; + Box? rawError; + try { + rawOutput = Box(await raw(arg0)); + } catch (e, s) { + rawError = Box(AnyhowException("$e\n\n$s")); + } + + final serializer = SseSerializer(generalizedFrbRustBinding); + assert((rawOutput != null) ^ (rawError != null)); + if (rawOutput != null) { + serializer.buffer.putUint8(0); + sse_encode_unit(rawOutput.value, serializer); + } else { + serializer.buffer.putUint8(1); + sse_encode_AnyhowException(rawError!.value, serializer); + } + final output = serializer.intoRaw(); + + generalizedFrbRustBinding.dartFnDeliverOutput( + callId: callId, + ptr: output.ptr, + rustVecLen: output.rustVecLen, + dataLen: output.dataLen, + ); + }; + } + + Future Function(int, dynamic, dynamic) + encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + FutureOr Function(String, String?) raw, + ) { + return (callId, rawArg0, rawArg1) async { + final arg0 = dco_decode_String(rawArg0); + final arg1 = dco_decode_opt_String(rawArg1); + + Box? rawOutput; + Box? rawError; + try { + rawOutput = Box(await raw(arg0, arg1)); + } catch (e, s) { + rawError = Box(AnyhowException("$e\n\n$s")); + } + + final serializer = SseSerializer(generalizedFrbRustBinding); + assert((rawOutput != null) ^ (rawError != null)); + if (rawOutput != null) { + serializer.buffer.putUint8(0); + sse_encode_unit(rawOutput.value, serializer); + } else { + serializer.buffer.putUint8(1); + sse_encode_AnyhowException(rawError!.value, serializer); + } + final output = serializer.intoRaw(); + + generalizedFrbRustBinding.dartFnDeliverOutput( + callId: callId, + ptr: output.ptr, + rustVecLen: output.rustVecLen, + dataLen: output.dataLen, + ); + }; + } + + Future Function(int) + encode_DartFn_Inputs__Output_opt_String_AnyhowException( + FutureOr Function() raw, + ) { + return (callId) async { + Box? rawOutput; + Box? rawError; + try { + rawOutput = Box(await raw()); + } catch (e, s) { + rawError = Box(AnyhowException("$e\n\n$s")); + } + + final serializer = SseSerializer(generalizedFrbRustBinding); + assert((rawOutput != null) ^ (rawError != null)); + if (rawOutput != null) { + serializer.buffer.putUint8(0); + sse_encode_opt_String(rawOutput.value, serializer); + } else { + serializer.buffer.putUint8(1); + sse_encode_AnyhowException(rawError!.value, serializer); + } + final output = serializer.intoRaw(); + + generalizedFrbRustBinding.dartFnDeliverOutput( + callId: callId, + ptr: output.ptr, + rustVecLen: output.rustVecLen, + dataLen: output.dataLen, + ); + }; + } + + Future Function(int, dynamic) + encode_DartFn_Inputs_bool_Output_unit_AnyhowException( + FutureOr Function(bool) raw, + ) { + return (callId, rawArg0) async { + final arg0 = dco_decode_bool(rawArg0); + + Box? rawOutput; + Box? rawError; + try { + rawOutput = Box(await raw(arg0)); + } catch (e, s) { + rawError = Box(AnyhowException("$e\n\n$s")); + } + + final serializer = SseSerializer(generalizedFrbRustBinding); + assert((rawOutput != null) ^ (rawError != null)); + if (rawOutput != null) { + serializer.buffer.putUint8(0); + sse_encode_unit(rawOutput.value, serializer); + } else { + serializer.buffer.putUint8(1); + sse_encode_AnyhowException(rawError!.value, serializer); + } + final output = serializer.intoRaw(); + + generalizedFrbRustBinding.dartFnDeliverOutput( + callId: callId, + ptr: output.ptr, + rustVecLen: output.rustVecLen, + dataLen: output.dataLen, + ); + }; + } + + Future Function(int, dynamic) + encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + FutureOr Function(WebSocketConnectionState) raw, + ) { + return (callId, rawArg0) async { + final arg0 = dco_decode_web_socket_connection_state(rawArg0); + + Box? rawOutput; + Box? rawError; + try { + rawOutput = Box(await raw(arg0)); + } catch (e, s) { + rawError = Box(AnyhowException("$e\n\n$s")); + } + + final serializer = SseSerializer(generalizedFrbRustBinding); + assert((rawOutput != null) ^ (rawError != null)); + if (rawOutput != null) { + serializer.buffer.putUint8(0); + sse_encode_unit(rawOutput.value, serializer); + } else { + serializer.buffer.putUint8(1); + sse_encode_AnyhowException(rawError!.value, serializer); + } + final output = serializer.intoRaw(); + + generalizedFrbRustBinding.dartFnDeliverOutput( + callId: callId, + ptr: output.ptr, + rustVecLen: output.rustVecLen, + dataLen: output.dataLen, + ); + }; + } + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_AuthHandle => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_AuthHandle => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_CallbackSubscriber => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_CallbackSubscriber => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_CallbackSubscriberDartFn => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_CallbackSubscriberDartFn => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_MobileConvexClient => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_MobileConvexClient => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient; + + RustArcIncrementStrongCountFnType + get rust_arc_increment_strong_count_SubscriptionHandle => wire + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle; + + RustArcDecrementStrongCountFnType + get rust_arc_decrement_strong_count_SubscriptionHandle => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AnyhowException(raw as String); + } + + @protected + AuthHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AuthHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriber + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalDcoDecode( + raw as List, + ); + } + + @protected + MobileConvexClient + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SubscriptionHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + AuthHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AuthHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriber + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalDcoDecode( + raw as List, + ); + } + + @protected + MobileConvexClient + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SubscriptionHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + FutureOr Function(String) + dco_decode_DartFn_Inputs_String_Output_unit_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(''); + } + + @protected + FutureOr Function(String, String?) + dco_decode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(''); + } + + @protected + FutureOr Function() + dco_decode_DartFn_Inputs__Output_opt_String_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(''); + } + + @protected + FutureOr Function(bool) + dco_decode_DartFn_Inputs_bool_Output_unit_AnyhowException(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(''); + } + + @protected + FutureOr Function(WebSocketConnectionState) + dco_decode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(''); + } + + @protected + Object dco_decode_DartOpaque(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return decodeDartOpaque(raw, generalizedFrbRustBinding); + } + + @protected + Map dco_decode_Map_String_String_None(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return Map.fromEntries( + dco_decode_list_record_string_string( + raw, + ).map((e) => MapEntry(e.$1, e.$2)), + ); + } + + @protected + AuthHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return AuthHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriber + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalDcoDecode(raw as List); + } + + @protected + CallbackSubscriberDartFn + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalDcoDecode( + raw as List, + ); + } + + @protected + MobileConvexClient + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalDcoDecode(raw as List); + } + + @protected + SubscriptionHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalDcoDecode(raw as List); + } + + @protected + String dco_decode_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as String; + } + + @protected + QuerySubscriber dco_decode_TraitDef_QuerySubscriber(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + throw UnimplementedError(); + } + + @protected + bool dco_decode_bool(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as bool; + } + + @protected + ClientError dco_decode_client_error(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + switch (raw[0]) { + case 0: + return ClientError_InternalError(msg: dco_decode_String(raw[1])); + case 1: + return ClientError_ConvexError(data: dco_decode_String(raw[1])); + case 2: + return ClientError_ServerError(msg: dco_decode_String(raw[1])); + default: + throw Exception("unreachable"); + } + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + PlatformInt64 dco_decode_isize(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeI64(raw); + } + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as Uint8List; + } + + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return (raw as List).map(dco_decode_record_string_string).toList(); + } + + @protected + String? dco_decode_opt_String(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_String(raw); + } + + @protected + (String, String) dco_decode_record_string_string(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + final arr = raw as List; + if (arr.length != 2) { + throw Exception('Expected 2 elements, got ${arr.length}'); + } + return (dco_decode_String(arr[0]), dco_decode_String(arr[1])); + } + + @protected + int dco_decode_u_8(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + + @protected + void dco_decode_unit(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return; + } + + @protected + BigInt dco_decode_usize(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dcoDecodeU64(raw); + } + + @protected + WebSocketConnectionState dco_decode_web_socket_connection_state(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return WebSocketConnectionState.values[raw as int]; + } + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_String(deserializer); + return AnyhowException(inner); + } + + @protected + AuthHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return AuthHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriber + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + MobileConvexClient + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + SubscriptionHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + AuthHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return AuthHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriber + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + MobileConvexClient + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + SubscriptionHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + Object sse_decode_DartOpaque(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_isize(deserializer); + return decodeDartOpaque(inner, generalizedFrbRustBinding); + } + + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_record_string_string(deserializer); + return Map.fromEntries(inner.map((e) => MapEntry(e.$1, e.$2))); + } + + @protected + AuthHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return AuthHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriber + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + CallbackSubscriberDartFn + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return CallbackSubscriberDartFnImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + MobileConvexClient + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return MobileConvexClientImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + SubscriptionHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return SubscriptionHandleImpl.frbInternalSseDecode( + sse_decode_usize(deserializer), + sse_decode_i_32(deserializer), + ); + } + + @protected + String sse_decode_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_list_prim_u_8_strict(deserializer); + return utf8.decoder.convert(inner); + } + + @protected + bool sse_decode_bool(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8() != 0; + } + + @protected + ClientError sse_decode_client_error(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var tag_ = sse_decode_i_32(deserializer); + switch (tag_) { + case 0: + var var_msg = sse_decode_String(deserializer); + return ClientError_InternalError(msg: var_msg); + case 1: + var var_data = sse_decode_String(deserializer); + return ClientError_ConvexError(data: var_data); + case 2: + var var_msg = sse_decode_String(deserializer); + return ClientError_ServerError(msg: var_msg); + default: + throw UnimplementedError(''); + } + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + + @protected + PlatformInt64 sse_decode_isize(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getPlatformInt64(); + } + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var len_ = sse_decode_i_32(deserializer); + return deserializer.buffer.getUint8List(len_); + } + + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + var len_ = sse_decode_i_32(deserializer); + var ans_ = <(String, String)>[]; + for (var idx_ = 0; idx_ < len_; ++idx_) { + ans_.add(sse_decode_record_string_string(deserializer)); + } + return ans_; + } + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_String(deserializer)); + } else { + return null; + } + } + + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var var_field0 = sse_decode_String(deserializer); + var var_field1 = sse_decode_String(deserializer); + return (var_field0, var_field1); + } + + @protected + int sse_decode_u_8(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getUint8(); + } + + @protected + void sse_decode_unit(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getBigUint64(); + } + + @protected + WebSocketConnectionState sse_decode_web_socket_connection_state( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return WebSocketConnectionState.values[inner]; + } + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.message, serializer); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as AuthHandleImpl).frbInternalSseEncode(move: true), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberImpl).frbInternalSseEncode(move: true), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberDartFnImpl).frbInternalSseEncode(move: true), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as MobileConvexClientImpl).frbInternalSseEncode(move: true), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SubscriptionHandleImpl).frbInternalSseEncode(move: true), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as AuthHandleImpl).frbInternalSseEncode(move: false), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberImpl).frbInternalSseEncode(move: false), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberDartFnImpl).frbInternalSseEncode(move: false), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as MobileConvexClientImpl).frbInternalSseEncode(move: false), + serializer, + ); + } + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SubscriptionHandleImpl).frbInternalSseEncode(move: false), + serializer, + ); + } + + @protected + void sse_encode_DartFn_Inputs_String_Output_unit_AnyhowException( + FutureOr Function(String) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_DartOpaque( + encode_DartFn_Inputs_String_Output_unit_AnyhowException(self), + serializer, + ); + } + + @protected + void sse_encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + FutureOr Function(String, String?) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_DartOpaque( + encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException(self), + serializer, + ); + } + + @protected + void sse_encode_DartFn_Inputs__Output_opt_String_AnyhowException( + FutureOr Function() self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_DartOpaque( + encode_DartFn_Inputs__Output_opt_String_AnyhowException(self), + serializer, + ); + } + + @protected + void sse_encode_DartFn_Inputs_bool_Output_unit_AnyhowException( + FutureOr Function(bool) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_DartOpaque( + encode_DartFn_Inputs_bool_Output_unit_AnyhowException(self), + serializer, + ); + } + + @protected + void + sse_encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + FutureOr Function(WebSocketConnectionState) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_DartOpaque( + encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + self, + ), + serializer, + ); + } + + @protected + void sse_encode_DartOpaque(Object self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_isize( + PlatformPointerUtil.ptrToPlatformInt64( + encodeDartOpaque( + self, + portManager.dartHandlerPort, + generalizedFrbRustBinding, + ), + ), + serializer, + ); + } + + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_record_string_string( + self.entries.map((e) => (e.key, e.value)).toList(), + serializer, + ); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as AuthHandleImpl).frbInternalSseEncode(move: null), + serializer, + ); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberImpl).frbInternalSseEncode(move: null), + serializer, + ); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as CallbackSubscriberDartFnImpl).frbInternalSseEncode(move: null), + serializer, + ); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as MobileConvexClientImpl).frbInternalSseEncode(move: null), + serializer, + ); + } + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_usize( + (self as SubscriptionHandleImpl).frbInternalSseEncode(move: null), + serializer, + ); + } + + @protected + void sse_encode_String(String self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_list_prim_u_8_strict(utf8.encoder.convert(self), serializer); + } + + @protected + void sse_encode_bool(bool self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self ? 1 : 0); + } + + @protected + void sse_encode_client_error(ClientError self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + switch (self) { + case ClientError_InternalError(msg: final msg): + sse_encode_i_32(0, serializer); + sse_encode_String(msg, serializer); + case ClientError_ConvexError(data: final data): + sse_encode_i_32(1, serializer); + sse_encode_String(data, serializer); + case ClientError_ServerError(msg: final msg): + sse_encode_i_32(2, serializer); + sse_encode_String(msg, serializer); + } + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + + @protected + void sse_encode_isize(PlatformInt64 self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putPlatformInt64(self); + } + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + serializer.buffer.putUint8List(self); + } + + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.length, serializer); + for (final item in self) { + sse_encode_record_string_string(item, serializer); + } + } + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_String(self, serializer); + } + } + + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_String(self.$1, serializer); + sse_encode_String(self.$2, serializer); + } + + @protected + void sse_encode_u_8(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putUint8(self); + } + + @protected + void sse_encode_unit(void self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + } + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putBigUint64(self); + } + + @protected + void sse_encode_web_socket_connection_state( + WebSocketConnectionState self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } +} + +@sealed +class AuthHandleImpl extends RustOpaque implements AuthHandle { + // Not to be used by end users + AuthHandleImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + AuthHandleImpl.frbInternalSseDecode(BigInt ptr, int externalSizeOnNative) + : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_AuthHandle, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_AuthHandle, + rustArcDecrementStrongCountPtr: + RustLib.instance.api.rust_arc_decrement_strong_count_AuthHandlePtr, + ); + + /// Disposes the auth session, stopping the token refresh loop and clearing authentication. + void dispose() => RustLib.instance.api.crateAuthHandleDispose(that: this); + + /// Returns whether the user is currently authenticated. + bool isAuthenticated() => + RustLib.instance.api.crateAuthHandleIsAuthenticated(that: this); +} + +@sealed +class CallbackSubscriberDartFnImpl extends RustOpaque + implements CallbackSubscriberDartFn { + // Not to be used by end users + CallbackSubscriberDartFnImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + CallbackSubscriberDartFnImpl.frbInternalSseDecode( + BigInt ptr, + int externalSizeOnNative, + ) : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: RustLib + .instance + .api + .rust_arc_increment_strong_count_CallbackSubscriberDartFn, + rustArcDecrementStrongCount: RustLib + .instance + .api + .rust_arc_decrement_strong_count_CallbackSubscriberDartFn, + rustArcDecrementStrongCountPtr: RustLib + .instance + .api + .rust_arc_decrement_strong_count_CallbackSubscriberDartFnPtr, + ); + + Future onError({required String message, String? value}) => + RustLib.instance.api.crateCallbackSubscriberDartFnOnError( + that: this, + message: message, + value: value, + ); + + Future onUpdate({required String value}) => RustLib.instance.api + .crateCallbackSubscriberDartFnOnUpdate(that: this, value: value); +} + +@sealed +class CallbackSubscriberImpl extends RustOpaque implements CallbackSubscriber { + // Not to be used by end users + CallbackSubscriberImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + CallbackSubscriberImpl.frbInternalSseDecode( + BigInt ptr, + int externalSizeOnNative, + ) : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_CallbackSubscriber, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_CallbackSubscriber, + rustArcDecrementStrongCountPtr: RustLib + .instance + .api + .rust_arc_decrement_strong_count_CallbackSubscriberPtr, + ); + + Future onError({required String message, String? value}) => + RustLib.instance.api.crateCallbackSubscriberOnError( + that: this, + message: message, + value: value, + ); + + Future onUpdate({required String value}) => RustLib.instance.api + .crateCallbackSubscriberOnUpdate(that: this, value: value); +} + +@sealed +class MobileConvexClientImpl extends RustOpaque implements MobileConvexClient { + // Not to be used by end users + MobileConvexClientImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + MobileConvexClientImpl.frbInternalSseDecode( + BigInt ptr, + int externalSizeOnNative, + ) : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_MobileConvexClient, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_MobileConvexClient, + rustArcDecrementStrongCountPtr: RustLib + .instance + .api + .rust_arc_decrement_strong_count_MobileConvexClientPtr, + ); + + /// Executes an action on the Convex backend. + Future action({ + required String name, + required Map args, + }) => RustLib.instance.api.crateMobileConvexClientAction( + that: this, + name: name, + args: args, + ); + + /// Executes a mutation on the Convex backend. + Future mutation({ + required String name, + required Map args, + }) => RustLib.instance.api.crateMobileConvexClientMutation( + that: this, + name: name, + args: args, + ); + + /// Sets up WebSocket connection state change listener. + /// + /// Must be called BEFORE any queries/mutations to capture all state changes. + /// The callback will be invoked whenever the WebSocket transitions between + /// Connected and Connecting states. + /// + /// # Arguments + /// + /// * `on_state_change` - Async callback invoked when connection state changes + /// + /// # Example + /// + /// ```dart + /// await client.onWebsocketStateChange( + /// onStateChange: (state) async { + /// print('Connection state: ${state.name}'); + /// }, + /// ); + /// ``` + Future onWebsocketStateChange({ + required FutureOr Function(WebSocketConnectionState) onStateChange, + }) => RustLib.instance.api.crateMobileConvexClientOnWebsocketStateChange( + that: this, + onStateChange: onStateChange, + ); + + /// Executes a query on the Convex backend. + Future query({ + required String name, + required Map args, + }) => RustLib.instance.api.crateMobileConvexClientQuery( + that: this, + name: name, + args: args, + ); + + /// Forces a WebSocket reconnect while retaining current client state. + Future reconnectNow({required String reason}) => RustLib.instance.api + .crateMobileConvexClientReconnectNow(that: this, reason: reason); + + /// Sets authentication token for the client. + Future setAuth({String? token}) => RustLib.instance.api + .crateMobileConvexClientSetAuth(that: this, token: token); + + /// Sets authentication with token refresh on every WebSocket reconnect. + /// + /// The callback is owned by the upstream Convex client so authentication + /// and query state are replayed together after a disconnect. + /// + /// Returns an AuthHandle that can be used to dispose the auth session. + Future setAuthWithRefresh({ + required FutureOr Function() fetchToken, + required FutureOr Function(bool) onAuthChange, + }) => RustLib.instance.api.crateMobileConvexClientSetAuthWithRefresh( + that: this, + fetchToken: fetchToken, + onAuthChange: onAuthChange, + ); + + /// Subscribes to real-time updates from a Convex query. + Future subscribe({ + required String name, + required Map args, + required FutureOr Function(String) onUpdate, + required FutureOr Function(String, String?) onError, + }) => RustLib.instance.api.crateMobileConvexClientSubscribe( + that: this, + name: name, + args: args, + onUpdate: onUpdate, + onError: onError, + ); +} + +@sealed +class SubscriptionHandleImpl extends RustOpaque implements SubscriptionHandle { + // Not to be used by end users + SubscriptionHandleImpl.frbInternalDcoDecode(List wire) + : super.frbInternalDcoDecode(wire, _kStaticData); + + // Not to be used by end users + SubscriptionHandleImpl.frbInternalSseDecode( + BigInt ptr, + int externalSizeOnNative, + ) : super.frbInternalSseDecode(ptr, externalSizeOnNative, _kStaticData); + + static final _kStaticData = RustArcStaticData( + rustArcIncrementStrongCount: + RustLib.instance.api.rust_arc_increment_strong_count_SubscriptionHandle, + rustArcDecrementStrongCount: + RustLib.instance.api.rust_arc_decrement_strong_count_SubscriptionHandle, + rustArcDecrementStrongCountPtr: RustLib + .instance + .api + .rust_arc_decrement_strong_count_SubscriptionHandlePtr, + ); + + /// Cancels the subscription by sending a cancellation signal. + void cancel() => + RustLib.instance.api.crateSubscriptionHandleCancel(that: this); +} diff --git a/third_party/convex_flutter/lib/src/rust/frb_generated.io.dart b/third_party/convex_flutter/lib/src/rust/frb_generated.io.dart new file mode 100644 index 00000000..7a8d9851 --- /dev/null +++ b/third_party/convex_flutter/lib/src/rust/frb_generated.io.dart @@ -0,0 +1,738 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +import 'dart:async'; +import 'dart:convert'; +import 'dart:ffi' as ffi; +import 'frb_generated.dart'; +import 'lib.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_AuthHandlePtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandlePtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberPtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberPtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberDartFnPtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFnPtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_MobileConvexClientPtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClientPtr; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_SubscriptionHandlePtr => wire + ._rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandlePtr; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + AuthHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + AuthHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + FutureOr Function(String) + dco_decode_DartFn_Inputs_String_Output_unit_AnyhowException(dynamic raw); + + @protected + FutureOr Function(String, String?) + dco_decode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + dynamic raw, + ); + + @protected + FutureOr Function() + dco_decode_DartFn_Inputs__Output_opt_String_AnyhowException(dynamic raw); + + @protected + FutureOr Function(bool) + dco_decode_DartFn_Inputs_bool_Output_unit_AnyhowException(dynamic raw); + + @protected + FutureOr Function(WebSocketConnectionState) + dco_decode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + dynamic raw, + ); + + @protected + Object dco_decode_DartOpaque(dynamic raw); + + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + + @protected + AuthHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + String dco_decode_String(dynamic raw); + + @protected + QuerySubscriber dco_decode_TraitDef_QuerySubscriber(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + ClientError dco_decode_client_error(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + PlatformInt64 dco_decode_isize(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + BigInt dco_decode_usize(dynamic raw); + + @protected + WebSocketConnectionState dco_decode_web_socket_connection_state(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + AuthHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + AuthHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + Object sse_decode_DartOpaque(SseDeserializer deserializer); + + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + + @protected + AuthHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + ClientError sse_decode_client_error(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + PlatformInt64 sse_decode_isize(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer); + + @protected + WebSocketConnectionState sse_decode_web_socket_connection_state( + SseDeserializer deserializer, + ); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_String_Output_unit_AnyhowException( + FutureOr Function(String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + FutureOr Function(String, String?) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs__Output_opt_String_AnyhowException( + FutureOr Function() self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_bool_Output_unit_AnyhowException( + FutureOr Function(bool) self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + FutureOr Function(WebSocketConnectionState) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartOpaque(Object self, SseSerializer serializer); + + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_client_error(ClientError self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_isize(PlatformInt64 self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_web_socket_connection_state( + WebSocketConnectionState self, + SseSerializer serializer, + ); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) => + RustLibWire(lib.ffiDynamicLibrary); + + /// Holds the symbol lookup function. + final ffi.Pointer Function(String symbolName) + _lookup; + + /// The symbols are looked up in [dynamicLibrary]. + RustLibWire(ffi.DynamicLibrary dynamicLibrary) + : _lookup = dynamicLibrary.lookup; + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandlePtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle', + ); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandlePtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandlePtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle', + ); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandlePtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber', + ); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberPtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber', + ); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberPtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFnPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn', + ); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFnPtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFnPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn', + ); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFnPtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClientPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient', + ); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClientPtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClientPtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient', + ); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClientPtr + .asFunction)>(); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ffi.Pointer ptr, + ) { + return _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr, + ); + } + + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandlePtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle', + ); + late final _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle = + _rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandlePtr + .asFunction)>(); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ffi.Pointer ptr, + ) { + return _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr, + ); + } + + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandlePtr = + _lookup)>>( + 'frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle', + ); + late final _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle = + _rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandlePtr + .asFunction)>(); +} diff --git a/third_party/convex_flutter/lib/src/rust/frb_generated.web.dart b/third_party/convex_flutter/lib/src/rust/frb_generated.web.dart new file mode 100644 index 00000000..0af0c816 --- /dev/null +++ b/third_party/convex_flutter/lib/src/rust/frb_generated.web.dart @@ -0,0 +1,698 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field + +// Static analysis wrongly picks the IO variant, thus ignore this +// ignore_for_file: argument_type_not_assignable + +import 'dart:async'; +import 'dart:convert'; +import 'frb_generated.dart'; +import 'lib.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart'; + +abstract class RustLibApiImplPlatform extends BaseApiImpl { + RustLibApiImplPlatform({ + required super.handler, + required super.wire, + required super.generalizedFrbRustBinding, + required super.portManager, + }); + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_AuthHandlePtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberPtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_CallbackSubscriberDartFnPtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_MobileConvexClientPtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient; + + CrossPlatformFinalizerArg + get rust_arc_decrement_strong_count_SubscriptionHandlePtr => wire + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle; + + @protected + AnyhowException dco_decode_AnyhowException(dynamic raw); + + @protected + AuthHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + AuthHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + FutureOr Function(String) + dco_decode_DartFn_Inputs_String_Output_unit_AnyhowException(dynamic raw); + + @protected + FutureOr Function(String, String?) + dco_decode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + dynamic raw, + ); + + @protected + FutureOr Function() + dco_decode_DartFn_Inputs__Output_opt_String_AnyhowException(dynamic raw); + + @protected + FutureOr Function(bool) + dco_decode_DartFn_Inputs_bool_Output_unit_AnyhowException(dynamic raw); + + @protected + FutureOr Function(WebSocketConnectionState) + dco_decode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + dynamic raw, + ); + + @protected + Object dco_decode_DartOpaque(dynamic raw); + + @protected + Map dco_decode_Map_String_String_None(dynamic raw); + + @protected + AuthHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + dynamic raw, + ); + + @protected + CallbackSubscriber + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + dynamic raw, + ); + + @protected + CallbackSubscriberDartFn + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + dynamic raw, + ); + + @protected + MobileConvexClient + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + dynamic raw, + ); + + @protected + SubscriptionHandle + dco_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + dynamic raw, + ); + + @protected + String dco_decode_String(dynamic raw); + + @protected + QuerySubscriber dco_decode_TraitDef_QuerySubscriber(dynamic raw); + + @protected + bool dco_decode_bool(dynamic raw); + + @protected + ClientError dco_decode_client_error(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + + @protected + PlatformInt64 dco_decode_isize(dynamic raw); + + @protected + Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + + @protected + List<(String, String)> dco_decode_list_record_string_string(dynamic raw); + + @protected + String? dco_decode_opt_String(dynamic raw); + + @protected + (String, String) dco_decode_record_string_string(dynamic raw); + + @protected + int dco_decode_u_8(dynamic raw); + + @protected + void dco_decode_unit(dynamic raw); + + @protected + BigInt dco_decode_usize(dynamic raw); + + @protected + WebSocketConnectionState dco_decode_web_socket_connection_state(dynamic raw); + + @protected + AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer); + + @protected + AuthHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + AuthHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + Object sse_decode_DartOpaque(SseDeserializer deserializer); + + @protected + Map sse_decode_Map_String_String_None( + SseDeserializer deserializer, + ); + + @protected + AuthHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriber + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + SseDeserializer deserializer, + ); + + @protected + CallbackSubscriberDartFn + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + SseDeserializer deserializer, + ); + + @protected + MobileConvexClient + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + SseDeserializer deserializer, + ); + + @protected + SubscriptionHandle + sse_decode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SseDeserializer deserializer, + ); + + @protected + String sse_decode_String(SseDeserializer deserializer); + + @protected + bool sse_decode_bool(SseDeserializer deserializer); + + @protected + ClientError sse_decode_client_error(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + + @protected + PlatformInt64 sse_decode_isize(SseDeserializer deserializer); + + @protected + Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + + @protected + List<(String, String)> sse_decode_list_record_string_string( + SseDeserializer deserializer, + ); + + @protected + String? sse_decode_opt_String(SseDeserializer deserializer); + + @protected + (String, String) sse_decode_record_string_string( + SseDeserializer deserializer, + ); + + @protected + int sse_decode_u_8(SseDeserializer deserializer); + + @protected + void sse_decode_unit(SseDeserializer deserializer); + + @protected + BigInt sse_decode_usize(SseDeserializer deserializer); + + @protected + WebSocketConnectionState sse_decode_web_socket_connection_state( + SseDeserializer deserializer, + ); + + @protected + void sse_encode_AnyhowException( + AnyhowException self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_Auto_Ref_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_String_Output_unit_AnyhowException( + FutureOr Function(String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + FutureOr Function(String, String?) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs__Output_opt_String_AnyhowException( + FutureOr Function() self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartFn_Inputs_bool_Output_unit_AnyhowException( + FutureOr Function(bool) self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + FutureOr Function(WebSocketConnectionState) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_DartOpaque(Object self, SseSerializer serializer); + + @protected + void sse_encode_Map_String_String_None( + Map self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + AuthHandle self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + CallbackSubscriber self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + CallbackSubscriberDartFn self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + MobileConvexClient self, + SseSerializer serializer, + ); + + @protected + void + sse_encode_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + SubscriptionHandle self, + SseSerializer serializer, + ); + + @protected + void sse_encode_String(String self, SseSerializer serializer); + + @protected + void sse_encode_bool(bool self, SseSerializer serializer); + + @protected + void sse_encode_client_error(ClientError self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + + @protected + void sse_encode_isize(PlatformInt64 self, SseSerializer serializer); + + @protected + void sse_encode_list_prim_u_8_strict( + Uint8List self, + SseSerializer serializer, + ); + + @protected + void sse_encode_list_record_string_string( + List<(String, String)> self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_String(String? self, SseSerializer serializer); + + @protected + void sse_encode_record_string_string( + (String, String) self, + SseSerializer serializer, + ); + + @protected + void sse_encode_u_8(int self, SseSerializer serializer); + + @protected + void sse_encode_unit(void self, SseSerializer serializer); + + @protected + void sse_encode_usize(BigInt self, SseSerializer serializer); + + @protected + void sse_encode_web_socket_connection_state( + WebSocketConnectionState self, + SseSerializer serializer, + ); +} + +// Section: wire_class + +class RustLibWire implements BaseWire { + RustLibWire.fromExternalLibrary(ExternalLibrary lib); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + int ptr, + ) => wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr, + ); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + int ptr, + ) => wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr, + ); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + int ptr, + ) => wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr, + ); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + int ptr, + ) => wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr, + ); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + int ptr, + ) => wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr, + ); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + int ptr, + ) => wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr, + ); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + int ptr, + ) => wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr, + ); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + int ptr, + ) => wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr, + ); + + void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + int ptr, + ) => wasmModule + .rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr, + ); + + void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + int ptr, + ) => wasmModule + .rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr, + ); +} + +@JS('wasm_bindgen') +external RustLibWasmModule get wasmModule; + +@JS() +@anonymous +extension type RustLibWasmModule._(JSObject _) implements JSObject { + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + int ptr, + ); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + int ptr, + ); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + int ptr, + ); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + int ptr, + ); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + int ptr, + ); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + int ptr, + ); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + int ptr, + ); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + int ptr, + ); + + external void + rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + int ptr, + ); + + external void + rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + int ptr, + ); +} diff --git a/third_party/convex_flutter/lib/src/rust/lib.dart b/third_party/convex_flutter/lib/src/rust/lib.dart new file mode 100644 index 00000000..a6e06b11 --- /dev/null +++ b/third_party/convex_flutter/lib/src/rust/lib.dart @@ -0,0 +1,161 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import + +import 'frb_generated.dart'; +import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; +import 'package:freezed_annotation/freezed_annotation.dart' hide protected; +part 'lib.freezed.dart'; + +// These functions are ignored because they are not marked as `pub`: `connected_client`, `handle_direct_function_result`, `internal_action`, `internal_mutation`, `internal_set_auth`, `internal_subscribe`, `new`, `new`, `parse_json_args` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `fmt`, `fmt`, `fmt`, `from`, `from` + +// Rust type: RustOpaqueMoi> +abstract class AuthHandle implements RustOpaqueInterface { + /// Disposes the auth session, stopping the token refresh loop and clearing authentication. + @override + void dispose(); + + /// Returns whether the user is currently authenticated. + bool isAuthenticated(); +} + +// Rust type: RustOpaqueMoi> +abstract class CallbackSubscriber + implements RustOpaqueInterface, QuerySubscriber { + @override + Future onError({required String message, String? value}); + + @override + Future onUpdate({required String value}); +} + +// Rust type: RustOpaqueMoi> +abstract class CallbackSubscriberDartFn + implements RustOpaqueInterface, QuerySubscriber { + @override + Future onError({required String message, String? value}); + + @override + Future onUpdate({required String value}); +} + +// Rust type: RustOpaqueMoi> +abstract class MobileConvexClient implements RustOpaqueInterface { + /// Executes an action on the Convex backend. + Future action({ + required String name, + required Map args, + }); + + /// Executes a mutation on the Convex backend. + Future mutation({ + required String name, + required Map args, + }); + + /// Creates a new MobileConvexClient instance with the given deployment URL and client ID. + factory MobileConvexClient({ + required String deploymentUrl, + required String clientId, + }) => RustLib.instance.api.crateMobileConvexClientNew( + deploymentUrl: deploymentUrl, + clientId: clientId, + ); + + /// Sets up WebSocket connection state change listener. + /// + /// Must be called BEFORE any queries/mutations to capture all state changes. + /// The callback will be invoked whenever the WebSocket transitions between + /// Connected and Connecting states. + /// + /// # Arguments + /// + /// * `on_state_change` - Async callback invoked when connection state changes + /// + /// # Example + /// + /// ```dart + /// await client.onWebsocketStateChange( + /// onStateChange: (state) async { + /// print('Connection state: ${state.name}'); + /// }, + /// ); + /// ``` + Future onWebsocketStateChange({ + required FutureOr Function(WebSocketConnectionState) onStateChange, + }); + + /// Executes a query on the Convex backend. + Future query({ + required String name, + required Map args, + }); + + /// Forces a WebSocket reconnect while retaining current client state. + Future reconnectNow({required String reason}); + + /// Sets authentication token for the client. + Future setAuth({String? token}); + + /// Sets authentication with token refresh on every WebSocket reconnect. + /// + /// The callback is owned by the upstream Convex client so authentication + /// and query state are replayed together after a disconnect. + /// + /// Returns an AuthHandle that can be used to dispose the auth session. + Future setAuthWithRefresh({ + required FutureOr Function() fetchToken, + required FutureOr Function(bool) onAuthChange, + }); + + /// Subscribes to real-time updates from a Convex query. + Future subscribe({ + required String name, + required Map args, + required FutureOr Function(String) onUpdate, + required FutureOr Function(String, String?) onError, + }); +} + +// Rust type: RustOpaqueMoi> +abstract class SubscriptionHandle implements RustOpaqueInterface { + /// Cancels the subscription by sending a cancellation signal. + void cancel(); +} + +abstract class QuerySubscriber { + Future onError({required String message, String? value}); + + Future onUpdate({required String value}); +} + +@freezed +sealed class ClientError with _$ClientError implements FrbException { + const ClientError._(); + + /// An internal error within the mobile Convex client. + const factory ClientError.internalError({required String msg}) = + ClientError_InternalError; + + /// An application-specific error from a remote Convex backend function. + const factory ClientError.convexError({required String data}) = + ClientError_ConvexError; + + /// An unexpected server-side error from a remote Convex function. + const factory ClientError.serverError({required String msg}) = + ClientError_ServerError; +} + +/// WebSocket connection state exposed to Flutter/Dart. +/// +/// This enum represents the current state of the WebSocket connection +/// to the Convex backend, allowing real-time connection monitoring. +enum WebSocketConnectionState { + /// The WebSocket is open and connected to the Convex backend. + connected, + + /// The WebSocket is closed and is connecting or reconnecting. + connecting, +} diff --git a/third_party/convex_flutter/lib/src/rust/lib.freezed.dart b/third_party/convex_flutter/lib/src/rust/lib.freezed.dart new file mode 100644 index 00000000..277a15fc --- /dev/null +++ b/third_party/convex_flutter/lib/src/rust/lib.freezed.dart @@ -0,0 +1,378 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'lib.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ClientError { + + + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ClientError); +} + + +@override +int get hashCode => runtimeType.hashCode; + +@override +String toString() { + return 'ClientError()'; +} + + +} + +/// @nodoc +class $ClientErrorCopyWith<$Res> { +$ClientErrorCopyWith(ClientError _, $Res Function(ClientError) __); +} + + +/// Adds pattern-matching-related methods to [ClientError]. +extension ClientErrorPatterns on ClientError { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( ClientError_InternalError value)? internalError,TResult Function( ClientError_ConvexError value)? convexError,TResult Function( ClientError_ServerError value)? serverError,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case ClientError_InternalError() when internalError != null: +return internalError(_that);case ClientError_ConvexError() when convexError != null: +return convexError(_that);case ClientError_ServerError() when serverError != null: +return serverError(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( ClientError_InternalError value) internalError,required TResult Function( ClientError_ConvexError value) convexError,required TResult Function( ClientError_ServerError value) serverError,}){ +final _that = this; +switch (_that) { +case ClientError_InternalError(): +return internalError(_that);case ClientError_ConvexError(): +return convexError(_that);case ClientError_ServerError(): +return serverError(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( ClientError_InternalError value)? internalError,TResult? Function( ClientError_ConvexError value)? convexError,TResult? Function( ClientError_ServerError value)? serverError,}){ +final _that = this; +switch (_that) { +case ClientError_InternalError() when internalError != null: +return internalError(_that);case ClientError_ConvexError() when convexError != null: +return convexError(_that);case ClientError_ServerError() when serverError != null: +return serverError(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String msg)? internalError,TResult Function( String data)? convexError,TResult Function( String msg)? serverError,required TResult orElse(),}) {final _that = this; +switch (_that) { +case ClientError_InternalError() when internalError != null: +return internalError(_that.msg);case ClientError_ConvexError() when convexError != null: +return convexError(_that.data);case ClientError_ServerError() when serverError != null: +return serverError(_that.msg);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String msg) internalError,required TResult Function( String data) convexError,required TResult Function( String msg) serverError,}) {final _that = this; +switch (_that) { +case ClientError_InternalError(): +return internalError(_that.msg);case ClientError_ConvexError(): +return convexError(_that.data);case ClientError_ServerError(): +return serverError(_that.msg);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String msg)? internalError,TResult? Function( String data)? convexError,TResult? Function( String msg)? serverError,}) {final _that = this; +switch (_that) { +case ClientError_InternalError() when internalError != null: +return internalError(_that.msg);case ClientError_ConvexError() when convexError != null: +return convexError(_that.data);case ClientError_ServerError() when serverError != null: +return serverError(_that.msg);case _: + return null; + +} +} + +} + +/// @nodoc + + +class ClientError_InternalError extends ClientError { + const ClientError_InternalError({required this.msg}): super._(); + + + final String msg; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ClientError_InternalErrorCopyWith get copyWith => _$ClientError_InternalErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ClientError_InternalError&&(identical(other.msg, msg) || other.msg == msg)); +} + + +@override +int get hashCode => Object.hash(runtimeType,msg); + +@override +String toString() { + return 'ClientError.internalError(msg: $msg)'; +} + + +} + +/// @nodoc +abstract mixin class $ClientError_InternalErrorCopyWith<$Res> implements $ClientErrorCopyWith<$Res> { + factory $ClientError_InternalErrorCopyWith(ClientError_InternalError value, $Res Function(ClientError_InternalError) _then) = _$ClientError_InternalErrorCopyWithImpl; +@useResult +$Res call({ + String msg +}); + + + + +} +/// @nodoc +class _$ClientError_InternalErrorCopyWithImpl<$Res> + implements $ClientError_InternalErrorCopyWith<$Res> { + _$ClientError_InternalErrorCopyWithImpl(this._self, this._then); + + final ClientError_InternalError _self; + final $Res Function(ClientError_InternalError) _then; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? msg = null,}) { + return _then(ClientError_InternalError( +msg: null == msg ? _self.msg : msg // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ClientError_ConvexError extends ClientError { + const ClientError_ConvexError({required this.data}): super._(); + + + final String data; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ClientError_ConvexErrorCopyWith get copyWith => _$ClientError_ConvexErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ClientError_ConvexError&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,data); + +@override +String toString() { + return 'ClientError.convexError(data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $ClientError_ConvexErrorCopyWith<$Res> implements $ClientErrorCopyWith<$Res> { + factory $ClientError_ConvexErrorCopyWith(ClientError_ConvexError value, $Res Function(ClientError_ConvexError) _then) = _$ClientError_ConvexErrorCopyWithImpl; +@useResult +$Res call({ + String data +}); + + + + +} +/// @nodoc +class _$ClientError_ConvexErrorCopyWithImpl<$Res> + implements $ClientError_ConvexErrorCopyWith<$Res> { + _$ClientError_ConvexErrorCopyWithImpl(this._self, this._then); + + final ClientError_ConvexError _self; + final $Res Function(ClientError_ConvexError) _then; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? data = null,}) { + return _then(ClientError_ConvexError( +data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +/// @nodoc + + +class ClientError_ServerError extends ClientError { + const ClientError_ServerError({required this.msg}): super._(); + + + final String msg; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ClientError_ServerErrorCopyWith get copyWith => _$ClientError_ServerErrorCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ClientError_ServerError&&(identical(other.msg, msg) || other.msg == msg)); +} + + +@override +int get hashCode => Object.hash(runtimeType,msg); + +@override +String toString() { + return 'ClientError.serverError(msg: $msg)'; +} + + +} + +/// @nodoc +abstract mixin class $ClientError_ServerErrorCopyWith<$Res> implements $ClientErrorCopyWith<$Res> { + factory $ClientError_ServerErrorCopyWith(ClientError_ServerError value, $Res Function(ClientError_ServerError) _then) = _$ClientError_ServerErrorCopyWithImpl; +@useResult +$Res call({ + String msg +}); + + + + +} +/// @nodoc +class _$ClientError_ServerErrorCopyWithImpl<$Res> + implements $ClientError_ServerErrorCopyWith<$Res> { + _$ClientError_ServerErrorCopyWithImpl(this._self, this._then); + + final ClientError_ServerError _self; + final $Res Function(ClientError_ServerError) _then; + +/// Create a copy of ClientError +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? msg = null,}) { + return _then(ClientError_ServerError( +msg: null == msg ? _self.msg : msg // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/third_party/convex_flutter/lib/src/utils.dart b/third_party/convex_flutter/lib/src/utils.dart new file mode 100644 index 00000000..f1dd0ae0 --- /dev/null +++ b/third_party/convex_flutter/lib/src/utils.dart @@ -0,0 +1,5 @@ +import 'dart:convert'; + +Map buildArgs(Map record) { + return {for (var entry in record.entries) entry.key: jsonEncode(entry.value)}; +} diff --git a/third_party/convex_flutter/linux/CMakeLists.txt b/third_party/convex_flutter/linux/CMakeLists.txt new file mode 100644 index 00000000..11cd02c1 --- /dev/null +++ b/third_party/convex_flutter/linux/CMakeLists.txt @@ -0,0 +1,19 @@ +# The Flutter tooling requires that developers have CMake 3.10 or later +# installed. You should not increase this version, as doing so will cause +# the plugin to fail to compile for some customers of the plugin. +cmake_minimum_required(VERSION 3.10) + +# Project-level configuration. +set(PROJECT_NAME "convex_flutter") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust convex_flutter "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(convex_flutter_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/third_party/convex_flutter/macos/Classes/dummy_file.c b/third_party/convex_flutter/macos/Classes/dummy_file.c new file mode 100644 index 00000000..e06dab99 --- /dev/null +++ b/third_party/convex_flutter/macos/Classes/dummy_file.c @@ -0,0 +1 @@ +// This is an empty file to force CocoaPods to create a framework. diff --git a/third_party/convex_flutter/macos/convex_flutter.podspec b/third_party/convex_flutter/macos/convex_flutter.podspec new file mode 100644 index 00000000..15b4329c --- /dev/null +++ b/third_party/convex_flutter/macos/convex_flutter.podspec @@ -0,0 +1,44 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint convex_flutter.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'convex_flutter' + s.version = '0.0.1' + s.summary = 'A new Flutter FFI plugin project.' + s.description = <<-DESC +A new Flutter FFI plugin project. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + + # This will ensure the source files in Classes/ are included in the native + # builds of apps using this FFI plugin. Podspec does not support relative + # paths, so Classes contains a forwarder C file that relatively imports + # `../src/*` so that the C sources can be shared among all target platforms. + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' + + s.script_phase = { + :name => 'Build Rust library', + # First argument is relative path to the `rust` folder, second is name of rust library + :script => 'sh "$PODS_TARGET_SRCROOT/../cargokit/build_pod.sh" ../rust convex_flutter', + :execution_position => :before_compile, + :input_files => ['${BUILT_PRODUCTS_DIR}/cargokit_phony'], + # Let XCode know that the static library referenced in -force_load below is + # created by this build step. + :output_files => ["${BUILT_PRODUCTS_DIR}/libconvex_flutter.a"], + } + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + # Flutter.framework does not contain a i386 slice. + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'OTHER_LDFLAGS' => '-force_load ${BUILT_PRODUCTS_DIR}/libconvex_flutter.a', + } +end diff --git a/third_party/convex_flutter/pubspec.yaml b/third_party/convex_flutter/pubspec.yaml new file mode 100644 index 00000000..5505d5bd --- /dev/null +++ b/third_party/convex_flutter/pubspec.yaml @@ -0,0 +1,95 @@ +name: convex_flutter +description: Multi-platform Convex backend integration for Flutter. Real-time WebSocket, subscriptions, auth, lifecycle management. Supports web (pure Dart) and native. +version: 3.0.1 +repository: https://github.com/jkuldev/convex_flutter +homepage: https://jkuldev.com + +environment: + sdk: ^3.8.1 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + flutter_rust_bridge: 2.11.1 + flutter_web_plugins: + sdk: flutter + freezed_annotation: ^3.1.0 + plugin_platform_interface: ^2.0.2 + http: ^1.2.0 # HTTP client for web REST fallback + web: ^1.0.0 # WebSocket API for web platform + +dev_dependencies: + ffi: ^2.1.3 + ffigen: ^13.0.0 + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + integration_test: + sdk: flutter + freezed: ^3.1.0 + build_runner: ^2.5.4 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + # + # Please refer to README.md for a detailed explanation. + plugin: + platforms: + android: + ffiPlugin: true + ios: + ffiPlugin: true + linux: + ffiPlugin: true + macos: + ffiPlugin: true + windows: + ffiPlugin: true + web: + pluginClass: ConvexFlutterWeb + fileName: convex_flutter_web.dart + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/third_party/convex_flutter/rust/Cargo.lock b/third_party/convex_flutter/rust/Cargo.lock new file mode 100644 index 00000000..a8ba7533 --- /dev/null +++ b/third_party/convex_flutter/rust/Cargo.lock @@ -0,0 +1,2187 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allo-isolate" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "449e356a4864c017286dbbec0e12767ea07efba29e3b7d984194c2a7ff3c4550" +dependencies = [ + "anyhow", + "atomic", + "backtrace", +] + +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + +[[package]] +name = "async-once-cell" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "build-target" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "832133bbabbbaa9fbdba793456a2827627a7d2b8fb96032fa1e7666d7895832b" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cc" +version = "1.2.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convex" +version = "0.10.4" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.13.1", + "bytes", + "convex_sync_types", + "futures", + "imbl", + "rand", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "convex_flutter" +version = "0.1.0" +dependencies = [ + "android_logger 0.14.1", + "anyhow", + "async-once-cell", + "convex", + "flutter_rust_bridge", + "futures", + "log", + "maplit", + "once_cell", + "parking_lot", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", +] + +[[package]] +name = "convex_sync_types" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba8235188b091cc50205a436bf6505cb386be208263fecdb4b62bd2b3c90a0a" +dependencies = [ + "anyhow", + "base64 0.13.1", + "bytes", + "derive_more", + "headers", + "rand", + "serde", + "serde_json", + "strum", + "uuid", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dart-sys" +version = "4.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57967e4b200d767d091b961d6ab42cc7d0cc14fe9e052e75d0d3cf9eb732d895" +dependencies = [ + "cc", +] + +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "delegate-attr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51aac4c99b2e6775164b412ea33ae8441b2fde2dbf05a20bc0052a63d08c475b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + +[[package]] +name = "flutter_rust_bridge" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde126295b2acc5f0a712e265e91b6fdc0ed38767496483e592ae7134db83725" +dependencies = [ + "allo-isolate", + "android_logger 0.15.1", + "anyhow", + "build-target", + "bytemuck", + "byteorder", + "console_error_panic_hook", + "dart-sys", + "delegate-attr", + "flutter_rust_bridge_macros", + "futures", + "js-sys", + "lazy_static", + "log", + "oslog", + "portable-atomic", + "threadpool", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "flutter_rust_bridge_macros" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f0420326b13675321b194928bb7830043b68cf8b810e1c651285c747abb080" +dependencies = [ + "hex", + "md-5", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imbl" +version = "7.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43ea8d4c37ee560727e824d62804183624d371e632019b0e9e3532bce64a33e5" +dependencies = [ + "archery", + "bitmaps", + "equivalent", + "imbl-sized-chunks", + "rand_core", + "rand_xoshiro", + "version_check", + "wide", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.4+3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", + "dashmap", + "log", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.17", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.5", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/third_party/convex_flutter/rust/Cargo.toml b/third_party/convex_flutter/rust/Cargo.toml new file mode 100644 index 00000000..ce117698 --- /dev/null +++ b/third_party/convex_flutter/rust/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "convex_flutter" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "staticlib"] + +[dependencies] +flutter_rust_bridge = "=2.11.1" +tokio = { version = "1", features = ["full"] } +android_logger = { version = "0.14.1" } +log = { version = "0.4.21" } +convex = { path = "../../convex_rs", features = ["rustls-tls-webpki-roots"] } +anyhow = { version = "1.0.86" } +thiserror = { version = "1.0.61" } +tokio-stream = { features = [ "io-util", "sync" ], version = "0.1" } +once_cell = { version = "1.19.0" } +futures = { version = "0.3" } +parking_lot = { version = "0.12.3" } +async-once-cell = { version = "0.5.3" } +serde_json = { version = "1.0.120" } +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] } + +[dev-dependencies] +maplit = { version = "1" } diff --git a/third_party/convex_flutter/rust/example/lib/main.dart b/third_party/convex_flutter/rust/example/lib/main.dart new file mode 100644 index 00000000..58dd592f --- /dev/null +++ b/third_party/convex_flutter/rust/example/lib/main.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:convex_flutter/convex_flutter.dart'; +import 'widgets/connection_status_indicator.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await ConvexClient.initialize( + ConvexConfig( + deploymentUrl: "https://your-deployment.convex.cloud", + clientId: "flutter-example-app", + operationTimeout: const Duration(seconds: 30), + ), + ); + + runApp(const ConvexExampleApp()); +} + +class ConvexExampleApp extends StatelessWidget { + const ConvexExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Convex Flutter - WebSocket State Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), + useMaterial3: true, + ), + home: const ConnectionDemoScreen(), + ); + } +} + +class ConnectionDemoScreen extends StatefulWidget { + const ConnectionDemoScreen({super.key}); + + @override + State createState() => _ConnectionDemoScreenState(); +} + +class _ConnectionDemoScreenState extends State { + final List _stateHistory = []; + + @override + void initState() { + super.initState(); + _listenToConnectionChanges(); + } + + void _listenToConnectionChanges() { + ConvexClient.instance.connectionState.listen((state) { + setState(() { + _stateHistory.insert(0, ConnectionEvent( + state: state, + timestamp: DateTime.now(), + )); + if (_stateHistory.length > 20) { + _stateHistory.removeLast(); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('WebSocket State Demo'), + actions: const [ConnectionStatusIndicator()], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCurrentStateCard(), + const SizedBox(height: 16), + _buildFeatureCard(), + const SizedBox(height: 16), + _buildHistoryCard(), + ], + ), + ), + ); + } + + Widget _buildCurrentStateCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: ConvexClient.instance.connectionState, + initialData: ConvexClient.instance.currentConnectionState, + builder: (context, snapshot) { + final state = snapshot.data!; + final isConnected = state == WebSocketConnectionState.connected; + return Column( + children: [ + Icon( + isConnected ? Icons.cloud_done : Icons.cloud_sync, + color: isConnected ? Colors.green : Colors.orange, + size: 64, + ), + const SizedBox(height: 12), + Text( + state.name.toUpperCase(), + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: isConnected ? Colors.green : Colors.orange, + ), + ), + Text('isConnected: ${ConvexClient.instance.isConnected}'), + ], + ); + }, + ), + ), + ); + } + + Widget _buildFeatureCard() { + return Card( + color: Colors.blue.shade50, + child: const Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('✨ Real-time Connection State', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + SizedBox(height: 8), + Text('• Automatic state updates\n' + '• Two states: Connected/Connecting\n' + '• Access via connectionState stream'), + ], + ), + ), + ); + } + + Widget _buildHistoryCard() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('History', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + TextButton( + onPressed: () => setState(() => _stateHistory.clear()), + child: const Text('Clear'), + ), + ], + ), + if (_stateHistory.isEmpty) + const Padding( + padding: EdgeInsets.all(16), + child: Text('No state changes yet'), + ) + else + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _stateHistory.length, + itemBuilder: (context, index) { + final event = _stateHistory[index]; + final isConnected = + event.state == WebSocketConnectionState.connected; + return ListTile( + leading: Icon( + isConnected ? Icons.cloud_done : Icons.cloud_sync, + color: isConnected ? Colors.green : Colors.orange, + ), + title: Text(event.state.name.toUpperCase()), + subtitle: Text(event.timestamp.toString()), + ); + }, + ), + ], + ), + ), + ); + } +} + +class ConnectionEvent { + final WebSocketConnectionState state; + final DateTime timestamp; + ConnectionEvent({required this.state, required this.timestamp}); +} diff --git a/third_party/convex_flutter/rust/src/frb_generated.rs b/third_party/convex_flutter/rust/src/frb_generated.rs new file mode 100644 index 00000000..69cf500f --- /dev/null +++ b/third_party/convex_flutter/rust/src/frb_generated.rs @@ -0,0 +1,2013 @@ +// This file is automatically generated, so please do not edit it. +// @generated by `flutter_rust_bridge`@ 2.11.1. + +#![allow( + non_camel_case_types, + unused, + non_snake_case, + clippy::needless_return, + clippy::redundant_closure_call, + clippy::redundant_closure, + clippy::useless_conversion, + clippy::unit_arg, + clippy::unused_unit, + clippy::double_parens, + clippy::let_and_return, + clippy::too_many_arguments, + clippy::match_single_binding, + clippy::clone_on_copy, + clippy::let_unit_value, + clippy::deref_addrof, + clippy::explicit_auto_deref, + clippy::borrow_deref_ref, + clippy::needless_borrow +)] + +// Section: imports + +use crate::QuerySubscriber; +use crate::*; +use flutter_rust_bridge::for_generated::byteorder::{NativeEndian, ReadBytesExt, WriteBytesExt}; +use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; +use flutter_rust_bridge::{Handler, IntoIntoDart}; + +// Section: boilerplate + +flutter_rust_bridge::frb_generated_boilerplate!( + default_stream_sink_codec = SseCodec, + default_rust_opaque = RustOpaqueMoi, + default_rust_auto_opaque = RustAutoOpaqueMoi, +); +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.11.1"; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = -829523767; + +// Section: executor + +flutter_rust_bridge::frb_generated_default_handler!(); + +// Section: wire_funcs + +fn wire__crate__AuthHandle_dispose_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "AuthHandle_dispose", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::AuthHandle::dispose(&*api_that_guard); + })?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__AuthHandle_is_authenticated_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "AuthHandle_is_authenticated", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + Result::<_, ()>::Ok(crate::AuthHandle::is_authenticated(&*api_that_guard))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__CallbackSubscriberDartFn_on_error_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "CallbackSubscriberDartFn_on_error", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_message = ::sse_decode(&mut deserializer); + let api_value = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::CallbackSubscriberDartFn::on_error( + &*api_that_guard, + api_message, + api_value, + ); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__CallbackSubscriberDartFn_on_update_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "CallbackSubscriberDartFn_on_update", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_value = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::CallbackSubscriberDartFn::on_update(&*api_that_guard, api_value); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__CallbackSubscriber_on_error_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "CallbackSubscriber_on_error", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_message = ::sse_decode(&mut deserializer); + let api_value = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::CallbackSubscriber::on_error( + &*api_that_guard, + api_message, + api_value, + ); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__CallbackSubscriber_on_update_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_normal::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "CallbackSubscriber_on_update", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_value = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| { + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::CallbackSubscriber::on_update(&*api_that_guard, api_value); + })?; + Ok(output_ok) + })()) + } + }, + ) +} +fn wire__crate__MobileConvexClient_action_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_action", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_args = + >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + crate::MobileConvexClient::action(&*api_that_guard, api_name, api_args) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_mutation_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_mutation", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_args = + >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = crate::MobileConvexClient::mutation( + &*api_that_guard, + api_name, + api_args, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_new_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_new", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_deployment_url = ::sse_decode(&mut deserializer); + let api_client_id = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok(crate::MobileConvexClient::new( + api_deployment_url, + api_client_id, + ))?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__MobileConvexClient_on_websocket_state_change_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_on_websocket_state_change", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_on_state_change = + decode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + ::sse_decode(&mut deserializer), + ); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = crate::MobileConvexClient::on_websocket_state_change( + &*api_that_guard, + api_on_state_change, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_query_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_query", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_args = + >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + crate::MobileConvexClient::query(&*api_that_guard, api_name, api_args) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_reconnect_now_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_reconnect_now", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_reason = ::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + crate::MobileConvexClient::reconnect_now(&*api_that_guard, api_reason) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_set_auth_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_set_auth", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_token = >::sse_decode(&mut deserializer); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = + crate::MobileConvexClient::set_auth(&*api_that_guard, api_token) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_set_auth_with_refresh_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_set_auth_with_refresh", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_fetch_token = decode_DartFn_Inputs__Output_opt_String_AnyhowException( + ::sse_decode(&mut deserializer), + ); + let api_on_auth_change = decode_DartFn_Inputs_bool_Output_unit_AnyhowException( + ::sse_decode(&mut deserializer), + ); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = crate::MobileConvexClient::set_auth_with_refresh( + &*api_that_guard, + api_fetch_token, + api_on_auth_change, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__MobileConvexClient_subscribe_impl( + port_: flutter_rust_bridge::for_generated::MessagePort, + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_async::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "MobileConvexClient_subscribe", + port: Some(port_), + mode: flutter_rust_bridge::for_generated::FfiCallMode::Normal, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + let api_name = ::sse_decode(&mut deserializer); + let api_args = + >::sse_decode(&mut deserializer); + let api_on_update = decode_DartFn_Inputs_String_Output_unit_AnyhowException( + ::sse_decode(&mut deserializer), + ); + let api_on_error = decode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + ::sse_decode(&mut deserializer), + ); + deserializer.end(); + move |context| async move { + transform_result_sse::<_, crate::ClientError>( + (move || async move { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order( + vec![flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + )], + ); + for i in decode_indices_ { + match i { + 0 => { + api_that_guard = + Some(api_that.lockable_decode_async_ref().await) + } + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = crate::MobileConvexClient::subscribe( + &*api_that_guard, + api_name, + api_args, + api_on_update, + api_on_error, + ) + .await?; + Ok(output_ok) + })() + .await, + ) + } + }, + ) +} +fn wire__crate__SubscriptionHandle_cancel_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "SubscriptionHandle_cancel", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_that = , + >>::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let mut api_that_guard = None; + let decode_indices_ = + flutter_rust_bridge::for_generated::lockable_compute_decode_order(vec![ + flutter_rust_bridge::for_generated::LockableOrderInfo::new( + &api_that, 0, false, + ), + ]); + for i in decode_indices_ { + match i { + 0 => api_that_guard = Some(api_that.lockable_decode_sync_ref()), + _ => unreachable!(), + } + } + let api_that_guard = api_that_guard.unwrap(); + let output_ok = Result::<_, ()>::Ok({ + crate::SubscriptionHandle::cancel(&*api_that_guard); + })?; + Ok(output_ok) + })()) + }, + ) +} + +// Section: related_funcs + +fn decode_DartFn_Inputs_String_Output_unit_AnyhowException( + dart_opaque: flutter_rust_bridge::DartOpaque, +) -> impl Fn(String) -> flutter_rust_bridge::DartFnFuture<()> { + use flutter_rust_bridge::IntoDart; + + async fn body(dart_opaque: flutter_rust_bridge::DartOpaque, arg0: String) -> () { + let args = vec![arg0.into_into_dart().into_dart()]; + let message = FLUTTER_RUST_BRIDGE_HANDLER + .dart_fn_invoke(dart_opaque, args) + .await; + + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let action = deserializer.cursor.read_u8().unwrap(); + let ans = match action { + 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), + 1 => std::result::Result::Err( + ::sse_decode(&mut deserializer), + ), + _ => unreachable!(), + }; + deserializer.end(); + let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); + ans + } + + move |arg0: String| { + flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( + dart_opaque.clone(), + arg0, + )) + } +} +fn decode_DartFn_Inputs_String_opt_String_Output_unit_AnyhowException( + dart_opaque: flutter_rust_bridge::DartOpaque, +) -> impl Fn(String, Option) -> flutter_rust_bridge::DartFnFuture<()> { + use flutter_rust_bridge::IntoDart; + + async fn body( + dart_opaque: flutter_rust_bridge::DartOpaque, + arg0: String, + arg1: Option, + ) -> () { + let args = vec![ + arg0.into_into_dart().into_dart(), + arg1.into_into_dart().into_dart(), + ]; + let message = FLUTTER_RUST_BRIDGE_HANDLER + .dart_fn_invoke(dart_opaque, args) + .await; + + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let action = deserializer.cursor.read_u8().unwrap(); + let ans = match action { + 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), + 1 => std::result::Result::Err( + ::sse_decode(&mut deserializer), + ), + _ => unreachable!(), + }; + deserializer.end(); + let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); + ans + } + + move |arg0: String, arg1: Option| { + flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( + dart_opaque.clone(), + arg0, + arg1, + )) + } +} +fn decode_DartFn_Inputs__Output_opt_String_AnyhowException( + dart_opaque: flutter_rust_bridge::DartOpaque, +) -> impl Fn() -> flutter_rust_bridge::DartFnFuture> { + use flutter_rust_bridge::IntoDart; + + async fn body(dart_opaque: flutter_rust_bridge::DartOpaque) -> Option { + let args = vec![]; + let message = FLUTTER_RUST_BRIDGE_HANDLER + .dart_fn_invoke(dart_opaque, args) + .await; + + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let action = deserializer.cursor.read_u8().unwrap(); + let ans = match action { + 0 => std::result::Result::Ok(>::sse_decode(&mut deserializer)), + 1 => std::result::Result::Err( + ::sse_decode(&mut deserializer), + ), + _ => unreachable!(), + }; + deserializer.end(); + let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); + ans + } + + move || { + flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body(dart_opaque.clone())) + } +} +fn decode_DartFn_Inputs_bool_Output_unit_AnyhowException( + dart_opaque: flutter_rust_bridge::DartOpaque, +) -> impl Fn(bool) -> flutter_rust_bridge::DartFnFuture<()> { + use flutter_rust_bridge::IntoDart; + + async fn body(dart_opaque: flutter_rust_bridge::DartOpaque, arg0: bool) -> () { + let args = vec![arg0.into_into_dart().into_dart()]; + let message = FLUTTER_RUST_BRIDGE_HANDLER + .dart_fn_invoke(dart_opaque, args) + .await; + + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let action = deserializer.cursor.read_u8().unwrap(); + let ans = match action { + 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), + 1 => std::result::Result::Err( + ::sse_decode(&mut deserializer), + ), + _ => unreachable!(), + }; + deserializer.end(); + let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); + ans + } + + move |arg0: bool| { + flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( + dart_opaque.clone(), + arg0, + )) + } +} +fn decode_DartFn_Inputs_web_socket_connection_state_Output_unit_AnyhowException( + dart_opaque: flutter_rust_bridge::DartOpaque, +) -> impl Fn(crate::WebSocketConnectionState) -> flutter_rust_bridge::DartFnFuture<()> { + use flutter_rust_bridge::IntoDart; + + async fn body( + dart_opaque: flutter_rust_bridge::DartOpaque, + arg0: crate::WebSocketConnectionState, + ) -> () { + let args = vec![arg0.into_into_dart().into_dart()]; + let message = FLUTTER_RUST_BRIDGE_HANDLER + .dart_fn_invoke(dart_opaque, args) + .await; + + let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let action = deserializer.cursor.read_u8().unwrap(); + let ans = match action { + 0 => std::result::Result::Ok(<()>::sse_decode(&mut deserializer)), + 1 => std::result::Result::Err( + ::sse_decode(&mut deserializer), + ), + _ => unreachable!(), + }; + deserializer.end(); + let ans = ans.expect("Dart throws exception but Rust side assume it is not failable"); + ans + } + + move |arg0: crate::WebSocketConnectionState| { + flutter_rust_bridge::for_generated::convert_into_dart_fn_future(body( + dart_opaque.clone(), + arg0, + )) + } +} +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); +flutter_rust_bridge::frb_generated_moi_arc_impl_value!( + flutter_rust_bridge::for_generated::RustAutoOpaqueInner +); + +// Section: dart2rust + +impl SseDecode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::anyhow::anyhow!("{}", inner); + } +} + +impl SseDecode for AuthHandle { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + +impl SseDecode for CallbackSubscriber { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + +impl SseDecode for CallbackSubscriberDartFn { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + +impl SseDecode for MobileConvexClient { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + +impl SseDecode for SubscriptionHandle { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = , + >>::sse_decode(deserializer); + return flutter_rust_bridge::for_generated::rust_auto_opaque_decode_owned(inner); + } +} + +impl SseDecode for flutter_rust_bridge::DartOpaque { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return unsafe { flutter_rust_bridge::for_generated::sse_decode_dart_opaque(inner) }; + } +} + +impl SseDecode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return inner.into_iter().collect(); + } +} + +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + +impl SseDecode + for RustOpaqueMoi< + flutter_rust_bridge::for_generated::RustAutoOpaqueInner, + > +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + +impl SseDecode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return decode_rust_opaque_moi(inner); + } +} + +impl SseDecode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = >::sse_decode(deserializer); + return String::from_utf8(inner).unwrap(); + } +} + +impl SseDecode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() != 0 + } +} + +impl SseDecode for crate::ClientError { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut tag_ = ::sse_decode(deserializer); + match tag_ { + 0 => { + let mut var_msg = ::sse_decode(deserializer); + return crate::ClientError::InternalError { msg: var_msg }; + } + 1 => { + let mut var_data = ::sse_decode(deserializer); + return crate::ClientError::ConvexError { data: var_data }; + } + 2 => { + let mut var_msg = ::sse_decode(deserializer); + return crate::ClientError::ServerError { msg: var_msg }; + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + +impl SseDecode for isize { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i64::().unwrap() as _ + } +} + +impl SseDecode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Vec<(String, String)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut len_ = ::sse_decode(deserializer); + let mut ans_ = vec![]; + for idx_ in 0..len_ { + ans_.push(<(String, String)>::sse_decode(deserializer)); + } + return ans_; + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + +impl SseDecode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut var_field0 = ::sse_decode(deserializer); + let mut var_field1 = ::sse_decode(deserializer); + return (var_field0, var_field1); + } +} + +impl SseDecode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u8().unwrap() + } +} + +impl SseDecode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {} +} + +impl SseDecode for usize { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_u64::().unwrap() as _ + } +} + +impl SseDecode for crate::WebSocketConnectionState { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::WebSocketConnectionState::Connected, + 1 => crate::WebSocketConnectionState::Connecting, + _ => unreachable!("Invalid variant for WebSocketConnectionState: {}", inner), + }; + } +} + +fn pde_ffi_dispatcher_primary_impl( + func_id: i32, + port: flutter_rust_bridge::for_generated::MessagePort, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 3 => wire__crate__CallbackSubscriberDartFn_on_error_impl(port, ptr, rust_vec_len, data_len), + 4 => { + wire__crate__CallbackSubscriberDartFn_on_update_impl(port, ptr, rust_vec_len, data_len) + } + 5 => wire__crate__CallbackSubscriber_on_error_impl(port, ptr, rust_vec_len, data_len), + 6 => wire__crate__CallbackSubscriber_on_update_impl(port, ptr, rust_vec_len, data_len), + 7 => wire__crate__MobileConvexClient_action_impl(port, ptr, rust_vec_len, data_len), + 8 => wire__crate__MobileConvexClient_mutation_impl(port, ptr, rust_vec_len, data_len), + 10 => wire__crate__MobileConvexClient_on_websocket_state_change_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 11 => wire__crate__MobileConvexClient_query_impl(port, ptr, rust_vec_len, data_len), + 12 => wire__crate__MobileConvexClient_reconnect_now_impl(port, ptr, rust_vec_len, data_len), + 13 => wire__crate__MobileConvexClient_set_auth_impl(port, ptr, rust_vec_len, data_len), + 14 => wire__crate__MobileConvexClient_set_auth_with_refresh_impl( + port, + ptr, + rust_vec_len, + data_len, + ), + 15 => wire__crate__MobileConvexClient_subscribe_impl(port, ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +fn pde_ffi_dispatcher_sync_impl( + func_id: i32, + ptr: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len: i32, + data_len: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + // Codec=Pde (Serialization + dispatch), see doc to use other codecs + match func_id { + 1 => wire__crate__AuthHandle_dispose_impl(ptr, rust_vec_len, data_len), + 2 => wire__crate__AuthHandle_is_authenticated_impl(ptr, rust_vec_len, data_len), + 9 => wire__crate__MobileConvexClient_new_impl(ptr, rust_vec_len, data_len), + 16 => wire__crate__SubscriptionHandle_cancel_impl(ptr, rust_vec_len, data_len), + _ => unreachable!(), + } +} + +// Section: rust2dart + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for FrbWrapper {} + +impl flutter_rust_bridge::IntoIntoDart> for AuthHandle { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} + +impl flutter_rust_bridge::IntoIntoDart> for CallbackSubscriber { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} + +impl flutter_rust_bridge::IntoIntoDart> + for CallbackSubscriberDartFn +{ + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} + +impl flutter_rust_bridge::IntoIntoDart> for MobileConvexClient { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for FrbWrapper { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self.0) + .into_dart() + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for FrbWrapper +{ +} + +impl flutter_rust_bridge::IntoIntoDart> for SubscriptionHandle { + fn into_into_dart(self) -> FrbWrapper { + self.into() + } +} + +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::ClientError { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + crate::ClientError::InternalError { msg } => { + [0.into_dart(), msg.into_into_dart().into_dart()].into_dart() + } + crate::ClientError::ConvexError { data } => { + [1.into_dart(), data.into_into_dart().into_dart()].into_dart() + } + crate::ClientError::ServerError { msg } => { + [2.into_dart(), msg.into_into_dart().into_dart()].into_dart() + } + _ => { + unimplemented!(""); + } + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::ClientError {} +impl flutter_rust_bridge::IntoIntoDart for crate::ClientError { + fn into_into_dart(self) -> crate::ClientError { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::WebSocketConnectionState { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Connected => 0.into_dart(), + Self::Connecting => 1.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::WebSocketConnectionState +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::WebSocketConnectionState +{ + fn into_into_dart(self) -> crate::WebSocketConnectionState { + self + } +} + +impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(format!("{:?}", self), serializer); + } +} + +impl SseEncode for AuthHandle { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + +impl SseEncode for CallbackSubscriber { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + +impl SseEncode for CallbackSubscriberDartFn { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + , + >>::sse_encode( + flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), + serializer, + ); + } +} + +impl SseEncode for MobileConvexClient { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + +impl SseEncode for SubscriptionHandle { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >>::sse_encode(flutter_rust_bridge::for_generated::rust_auto_opaque_encode::<_, MoiArc<_>>(self), serializer); + } +} + +impl SseEncode for flutter_rust_bridge::DartOpaque { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.encode(), serializer); + } +} + +impl SseEncode for std::collections::HashMap { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_iter().collect(), serializer); + } +} + +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + +impl SseEncode + for RustOpaqueMoi< + flutter_rust_bridge::for_generated::RustAutoOpaqueInner, + > +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + +impl SseEncode + for RustOpaqueMoi> +{ + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + let (ptr, size) = self.sse_encode_raw(); + ::sse_encode(ptr, serializer); + ::sse_encode(size, serializer); + } +} + +impl SseEncode for String { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + >::sse_encode(self.into_bytes(), serializer); + } +} + +impl SseEncode for bool { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self as _).unwrap(); + } +} + +impl SseEncode for crate::ClientError { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + match self { + crate::ClientError::InternalError { msg } => { + ::sse_encode(0, serializer); + ::sse_encode(msg, serializer); + } + crate::ClientError::ConvexError { data } => { + ::sse_encode(1, serializer); + ::sse_encode(data, serializer); + } + crate::ClientError::ServerError { msg } => { + ::sse_encode(2, serializer); + ::sse_encode(msg, serializer); + } + _ => { + unimplemented!(""); + } + } + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + +impl SseEncode for isize { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer + .cursor + .write_i64::(self as _) + .unwrap(); + } +} + +impl SseEncode for Vec { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + ::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Vec<(String, String)> { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.len() as _, serializer); + for item in self { + <(String, String)>::sse_encode(item, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for (String, String) { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.0, serializer); + ::sse_encode(self.1, serializer); + } +} + +impl SseEncode for u8 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_u8(self).unwrap(); + } +} + +impl SseEncode for () { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {} +} + +impl SseEncode for usize { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer + .cursor + .write_u64::(self as _) + .unwrap(); + } +} + +impl SseEncode for crate::WebSocketConnectionState { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::WebSocketConnectionState::Connected => 0, + crate::WebSocketConnectionState::Connecting => 1, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +#[cfg(not(target_family = "wasm"))] +mod io { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use crate::QuerySubscriber; + use crate::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_io!(); + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[unsafe(no_mangle)] + pub extern "C" fn frbgen_convex_flutter_rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } +} +#[cfg(not(target_family = "wasm"))] +pub use io::*; + +/// cbindgen:ignore +#[cfg(target_family = "wasm")] +mod web { + // This file is automatically generated, so please do not edit it. + // @generated by `flutter_rust_bridge`@ 2.11.1. + + // Section: imports + + use super::*; + use crate::QuerySubscriber; + use crate::*; + use flutter_rust_bridge::for_generated::byteorder::{ + NativeEndian, ReadBytesExt, WriteBytesExt, + }; + use flutter_rust_bridge::for_generated::wasm_bindgen; + use flutter_rust_bridge::for_generated::wasm_bindgen::prelude::*; + use flutter_rust_bridge::for_generated::{transform_result_dco, Lifetimeable, Lockable}; + use flutter_rust_bridge::{Handler, IntoIntoDart}; + + // Section: boilerplate + + flutter_rust_bridge::frb_generated_boilerplate_web!(); + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerAuthHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriber( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerCallbackSubscriberDartFn( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerMobileConvexClient( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_increment_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::increment_strong_count(ptr as _); + } + + #[wasm_bindgen] + pub fn rust_arc_decrement_strong_count_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerSubscriptionHandle( + ptr: *const std::ffi::c_void, + ) { + MoiArc::>::decrement_strong_count(ptr as _); + } +} +#[cfg(target_family = "wasm")] +pub use web::*; diff --git a/third_party/convex_flutter/rust/src/lib.rs b/third_party/convex_flutter/rust/src/lib.rs new file mode 100644 index 00000000..289ab6d0 --- /dev/null +++ b/third_party/convex_flutter/rust/src/lib.rs @@ -0,0 +1,564 @@ +mod frb_generated; +use std::{ + collections::{BTreeMap, HashMap}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, +}; + +#[cfg(debug_assertions)] +use android_logger::Config; +use async_once_cell::OnceCell; +use convex::{ + AuthTokenFetcher, + AuthenticationToken, + ConvexClient, + ConvexClientBuilder, + FunctionResult, + Value, // Convex client and result types + WebSocketState as ConvexWebSocketState, +}; +use flutter_rust_bridge::{frb, DartFnFuture}; +use futures::{ + channel::oneshot::{self, Sender}, + pin_mut, select_biased, FutureExt, StreamExt, +}; +use log::debug; // Logging for debugging purposes +#[cfg(debug_assertions)] +use log::LevelFilter; +use parking_lot::Mutex; +// Custom error type for Convex client operations, exposed to Dart. +#[derive(Debug, thiserror::Error)] +#[frb] +pub enum ClientError { + /// An internal error within the mobile Convex client. + #[error("InternalError: {msg}")] + InternalError { msg: String }, + /// An application-specific error from a remote Convex backend function. + #[error("ConvexError: {data}")] + ConvexError { data: String }, + /// An unexpected server-side error from a remote Convex function. + #[error("ServerError: {msg}")] + ServerError { msg: String }, +} + +impl From for ClientError { + fn from(value: anyhow::Error) -> Self { + Self::InternalError { + msg: value.to_string(), + } + } +} + +/// WebSocket connection state exposed to Flutter/Dart. +/// +/// This enum represents the current state of the WebSocket connection +/// to the Convex backend, allowing real-time connection monitoring. +#[derive(Debug, Clone)] +#[frb] +pub enum WebSocketConnectionState { + /// The WebSocket is open and connected to the Convex backend. + Connected, + /// The WebSocket is closed and is connecting or reconnecting. + Connecting, +} + +impl From for WebSocketConnectionState { + fn from(state: ConvexWebSocketState) -> Self { + match state { + ConvexWebSocketState::Connected => WebSocketConnectionState::Connected, + ConvexWebSocketState::Connecting => WebSocketConnectionState::Connecting, + } + } +} + +/// Trait defining the interface for handling subscription updates. +// Not directly exposed to Dart, used internally by subscribers. +pub trait QuerySubscriber: Send + Sync { + fn on_update(&self, value: String); // Called when a new update is received + fn on_error(&self, message: String, value: Option); // Called on error with optional value +} + +/// Adapter struct to implement QuerySubscriber using Dart callbacks. +pub struct CallbackSubscriber { + on_update: Box, // Callback for updates + on_error: Box) + Send + Sync>, // Callback for errors +} + +impl QuerySubscriber for CallbackSubscriber { + fn on_update(&self, value: String) { + (self.on_update)(value); + } + + fn on_error(&self, message: String, value: Option) { + (self.on_error)(message, value); + } +} + +/// Opaque type for Dart, representing a subscription handle with cancellation. +#[frb(opaque)] +pub struct SubscriptionHandle { + cancel_sender: Arc>>>, // Sender to cancel the subscription +} + +impl SubscriptionHandle { + fn new(cancel_sender: Sender<()>) -> Self { + SubscriptionHandle { + cancel_sender: Arc::new(Mutex::new(Some(cancel_sender))), + } + } + + /// Cancels the subscription by sending a cancellation signal. + #[frb(sync)] + pub fn cancel(&self) { + if let Some(sender) = self.cancel_sender.lock().take() { + sender.send(()).unwrap(); + } + } +} + +/// Opaque type for Dart, representing an auth session handle with lifecycle management. +/// Used to control the token refresh loop and check authentication state. +#[frb(opaque)] +pub struct AuthHandle { + cancel_sender: Arc>>>, + is_authenticated: Arc, +} + +impl AuthHandle { + fn new(cancel_sender: Sender<()>, is_authenticated: Arc) -> Self { + AuthHandle { + cancel_sender: Arc::new(Mutex::new(Some(cancel_sender))), + is_authenticated, + } + } + + /// Disposes the auth session, stopping the token refresh loop and clearing authentication. + #[frb(sync)] + pub fn dispose(&self) { + if let Some(sender) = self.cancel_sender.lock().take() { + let _ = sender.send(()); + } + } + + /// Returns whether the user is currently authenticated. + #[frb(sync)] + pub fn is_authenticated(&self) -> bool { + self.is_authenticated.load(Ordering::SeqCst) + } +} + +/// Adapter for Dart functions as subscribers, handling async callbacks. +pub struct CallbackSubscriberDartFn { + on_update: Box DartFnFuture<()> + Send + Sync>, // Async update callback + on_error: Box) -> DartFnFuture<()> + Send + Sync>, // Async error callback +} + +impl QuerySubscriber for CallbackSubscriberDartFn { + fn on_update(&self, value: String) { + let future = (self.on_update)(value); + tokio::spawn(async move { + let _ = future.await; // Await the future, ignoring the result + }); + } + + fn on_error(&self, message: String, value: Option) { + let future = (self.on_error)(message, value); + tokio::spawn(async move { + let _ = future.await; + }); + } +} + +/// Main Convex client struct, opaque to Dart, managing connections and operations. +#[frb(opaque)] +pub struct MobileConvexClient { + deployment_url: String, // URL of the Convex deployment + client_id: String, // Client ID for authentication + client: OnceCell, // Lazy-initialized Convex client + rt: tokio::runtime::Runtime, // Tokio runtime for async operations + auth_generation: Arc, + // Channel sender for WebSocket state change notifications + state_change_sender: Arc>>>, +} + +impl MobileConvexClient { + /// Creates a new MobileConvexClient instance with the given deployment URL and client ID. + #[frb(sync)] + pub fn new(deployment_url: String, client_id: String) -> MobileConvexClient { + #[cfg(debug_assertions)] + android_logger::init_once(Config::default().with_max_level(LevelFilter::Error)); + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + MobileConvexClient { + deployment_url, + client_id, + client: OnceCell::new(), + rt, + auth_generation: Arc::new(AtomicU64::new(0)), + state_change_sender: Arc::new(Mutex::new(None)), + } + } + + /// Sets up WebSocket connection state change listener. + /// + /// Must be called BEFORE any queries/mutations to capture all state changes. + /// The callback will be invoked whenever the WebSocket transitions between + /// Connected and Connecting states. + /// + /// # Arguments + /// + /// * `on_state_change` - Async callback invoked when connection state changes + /// + /// # Example + /// + /// ```dart + /// await client.onWebsocketStateChange( + /// onStateChange: (state) async { + /// print('Connection state: ${state.name}'); + /// }, + /// ); + /// ``` + #[frb] + pub async fn on_websocket_state_change( + &self, + on_state_change: impl Fn(WebSocketConnectionState) -> DartFnFuture<()> + Send + Sync + 'static, + ) -> Result<(), ClientError> { + println!("RUST: on_websocket_state_change() called"); + + // Create tokio mpsc channel for receiving state changes from convex client + let (state_tx, mut state_rx) = tokio::sync::mpsc::channel::(10); + println!("RUST: Created mpsc channel for state changes"); + + // Store sender for use when initializing the client + { + let mut sender = self.state_change_sender.lock(); + *sender = Some(state_tx); + println!("RUST: Stored state_tx in state_change_sender"); + } + + // Spawn task to listen for state changes and call Dart callback + let on_state_change = Arc::new(on_state_change); + println!("RUST: Spawning listener task for state changes"); + self.rt.spawn(async move { + println!("RUST: Listener task started, waiting for state changes"); + while let Some(state) = state_rx.recv().await { + println!("RUST: Received state change from channel: {:?}", state); + let dart_state = WebSocketConnectionState::from(state); + println!("RUST: Converted to Dart state: {:?}", dart_state); + let callback = on_state_change.clone(); + let future = (callback)(dart_state); + println!("RUST: Calling Dart callback"); + let _ = future.await; + println!("RUST: Dart callback completed"); + } + println!("RUST: Listener task exiting (channel closed)"); + }); + + println!("RUST: on_websocket_state_change() returning"); + Ok(()) + } + + /// Retrieves or initializes a connected Convex client. + async fn connected_client(&self) -> anyhow::Result { + let url = self.deployment_url.clone(); + let state_sender = self.state_change_sender.lock().clone(); + + println!( + "RUST: connected_client() called with sender: {:?}", + state_sender.is_some() + ); + + self.client + .get_or_try_init(async { + let client_id = self.client_id.to_owned(); + + // Build client directly without spawning a task + // This ensures callback is registered BEFORE connection starts + println!("RUST: Building ConvexClient directly (no task spawn)"); + let mut builder = ConvexClientBuilder::new(url.as_str()).with_client_id(&client_id); + + // Register state change callback BEFORE building + if let Some(sender) = state_sender { + println!("RUST: Registering state change callback with builder"); + builder = builder.with_on_state_change(sender); + } else { + println!( + "RUST WARNING: No sender available - state changes will not be emitted" + ); + } + + println!("RUST: Calling builder.build() - connection will start now"); + let result = builder.build().await; + match &result { + Ok(_) => println!("RUST: ConvexClient built successfully"), + Err(e) => println!("RUST ERROR: Failed to build ConvexClient: {:?}", e), + } + result + }) + .await + .map(|client_ref| client_ref.clone()) + } + + /// Executes a query on the Convex backend. + #[frb] + pub async fn query( + &self, + name: String, + args: HashMap, + ) -> Result { + let mut client = self.connected_client().await?; + debug!("got the client"); + let result = client.query(name.as_str(), parse_json_args(args)).await?; + debug!("got the result"); + handle_direct_function_result(result) + } + + /// Subscribes to real-time updates from a Convex query. + #[frb] + pub async fn subscribe( + &self, + name: String, + args: HashMap, + on_update: impl Fn(String) -> DartFnFuture<()> + Send + Sync + 'static, + on_error: impl Fn(String, Option) -> DartFnFuture<()> + Send + Sync + 'static, + ) -> Result { + let subscriber = Arc::new(CallbackSubscriberDartFn { + on_update: Box::new(on_update), + on_error: Box::new(on_error), + }); + self.internal_subscribe(name, args, subscriber) + .await + .map_err(Into::into) + } + + /// Internal method for subscription logic. + async fn internal_subscribe( + &self, + name: String, + args: HashMap, + subscriber: Arc, + ) -> anyhow::Result { + let mut client = self.connected_client().await?; + debug!("New subscription"); + let mut subscription = client + .subscribe(name.as_str(), parse_json_args(args)) + .await?; + let (cancel_sender, cancel_receiver) = oneshot::channel::<()>(); + self.rt.spawn(async move { + let cancel_fut = cancel_receiver.fuse(); + pin_mut!(cancel_fut); + loop { + select_biased! { + new_val = subscription.next().fuse() => { + let new_val = match new_val { + Some(val) => val, + None => { + log::warn!("Subscription stream ended for {}", &name); + break; + } + }; + match new_val { + FunctionResult::Value(value) => { + debug!("Updating with {value:?}"); + subscriber.on_update(serde_json::to_string( + &serde_json::Value::from(value), + ).unwrap()); + } + FunctionResult::ErrorMessage(message) => { + subscriber.on_error(message, None); + } + FunctionResult::ConvexError(error) => subscriber.on_error( + error.message, + Some(serde_json::ser::to_string( + &serde_json::Value::from(error.data), + ).unwrap()), + ), + } + } + _ = cancel_fut => { + break; + } + } + } + debug!("Subscription canceled"); + }); + Ok(SubscriptionHandle::new(cancel_sender)) + } + + /// Executes a mutation on the Convex backend. + #[frb] + pub async fn mutation( + &self, + name: String, + args: HashMap, + ) -> Result { + let result = self.internal_mutation(name, args).await?; + handle_direct_function_result(result) + } + + /// Internal method for mutation logic. + async fn internal_mutation( + &self, + name: String, + args: HashMap, + ) -> anyhow::Result { + let mut client = self.connected_client().await?; + self.rt + .spawn(async move { client.mutation(&name, parse_json_args(args)).await }) + .await? + } + + /// Executes an action on the Convex backend. + #[frb] + pub async fn action( + &self, + name: String, + args: HashMap, + ) -> Result { + debug!("Running action: {}", name); + let result = self.internal_action(name, args).await?; + debug!("Got action result: {:?}", result); + handle_direct_function_result(result) + } + + /// Internal method for action logic. + async fn internal_action( + &self, + name: String, + args: HashMap, + ) -> anyhow::Result { + let mut client = self.connected_client().await?; + debug!("Running action: {}", name); + self.rt + .spawn(async move { client.action(&name, parse_json_args(args)).await }) + .await? + } + + /// Sets authentication token for the client. + #[frb] + pub async fn set_auth(&self, token: Option) -> Result<(), ClientError> { + Ok(self.internal_set_auth(token).await?) + } + + /// Internal method for setting authentication. + async fn internal_set_auth(&self, token: Option) -> anyhow::Result<()> { + // Invalidate any older refresh handle before replacing its callback. + // A delayed disposal from that handle must not clear this auth state. + self.auth_generation.fetch_add(1, Ordering::SeqCst); + let mut client = self.connected_client().await?; + self.rt + .spawn(async move { client.set_auth(token).await }) + .await + .map_err(|e| e.into()) + } + + /// Forces a WebSocket reconnect while retaining current client state. + #[frb] + pub async fn reconnect_now(&self, reason: String) -> Result<(), ClientError> { + let mut client = self.connected_client().await?; + self.rt + .spawn(async move { client.reconnect_now(&reason).await }) + .await + .map_err(|e| ClientError::InternalError { msg: e.to_string() })?; + Ok(()) + } + + /// Sets authentication with token refresh on every WebSocket reconnect. + /// + /// The callback is owned by the upstream Convex client so authentication + /// and query state are replayed together after a disconnect. + /// + /// Returns an AuthHandle that can be used to dispose the auth session. + #[frb] + pub async fn set_auth_with_refresh( + &self, + fetch_token: impl Fn() -> DartFnFuture> + Send + Sync + 'static, + on_auth_change: impl Fn(bool) -> DartFnFuture<()> + Send + Sync + 'static, + ) -> Result { + let is_authenticated = Arc::new(AtomicBool::new(false)); + let (cancel_sender, cancel_receiver) = oneshot::channel::<()>(); + let generation = self.auth_generation.fetch_add(1, Ordering::SeqCst) + 1; + + let mut client = self.connected_client().await?; + let fetch_token = Arc::new(fetch_token); + let on_auth_change = Arc::new(on_auth_change); + let cancel_on_auth_change = on_auth_change.clone(); + let callback_is_authenticated = is_authenticated.clone(); + let callback: AuthTokenFetcher = Box::new(move |_force_refresh| { + let fetch_token = fetch_token.clone(); + let on_auth_change = on_auth_change.clone(); + let is_authenticated = callback_is_authenticated.clone(); + Box::pin(async move { + let token = (fetch_token)().await; + let next_is_authenticated = token.is_some(); + let changed = is_authenticated.swap(next_is_authenticated, Ordering::SeqCst) + != next_is_authenticated; + if changed { + let _ = (on_auth_change)(next_is_authenticated).await; + } + Ok(match token { + Some(token) => AuthenticationToken::User(token), + None => AuthenticationToken::None, + }) + }) + }); + client.set_auth_callback(Some(callback)).await; + + let cancel_is_authenticated = is_authenticated.clone(); + let cancel_auth_generation = self.auth_generation.clone(); + self.rt.spawn(async move { + let _ = cancel_receiver.await; + if cancel_auth_generation + .compare_exchange( + generation, + generation + 1, + Ordering::SeqCst, + Ordering::SeqCst, + ) + .is_err() + { + return; + } + let mut client = client.clone(); + client.set_auth_callback(None).await; + if cancel_is_authenticated.swap(false, Ordering::SeqCst) { + let _ = (cancel_on_auth_change)(false).await; + } + }); + + Ok(AuthHandle::new(cancel_sender, is_authenticated)) + } +} + +/// Utility function to parse HashMap arguments into Convex Value format. +fn parse_json_args(raw_args: HashMap) -> BTreeMap { + raw_args + .into_iter() + .map(|(k, v)| { + ( + k, + Value::try_from( + serde_json::from_str::(&v) + .expect("Invalid JSON data from FFI"), + ) + .expect("Invalid Convex data from FFI"), + ) + }) + .collect() +} + +/// Utility function to handle and serialize FunctionResult into a string or error. +fn handle_direct_function_result(result: FunctionResult) -> Result { + match result { + FunctionResult::Value(v) => serde_json::to_string(&serde_json::Value::from(v)) + .map_err(|e| ClientError::InternalError { msg: e.to_string() }), + FunctionResult::ConvexError(e) => Err(ClientError::ConvexError { + data: serde_json::ser::to_string(&serde_json::Value::from(e.data)).unwrap(), + }), + FunctionResult::ErrorMessage(msg) => Err(ClientError::ServerError { msg }), + } +} diff --git a/third_party/convex_flutter/test_driver/integration_test.dart b/third_party/convex_flutter/test_driver/integration_test.dart new file mode 100644 index 00000000..b38629cc --- /dev/null +++ b/third_party/convex_flutter/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/third_party/convex_flutter/windows/CMakeLists.txt b/third_party/convex_flutter/windows/CMakeLists.txt new file mode 100644 index 00000000..4c825e7a --- /dev/null +++ b/third_party/convex_flutter/windows/CMakeLists.txt @@ -0,0 +1,20 @@ +# The Flutter tooling requires that developers have a version of Visual Studio +# installed that includes CMake 3.14 or later. You should not increase this +# version, as doing so will cause the plugin to fail to compile for some +# customers of the plugin. +cmake_minimum_required(VERSION 3.14) + +# Project-level configuration. +set(PROJECT_NAME "convex_flutter") +project(${PROJECT_NAME} LANGUAGES CXX) + +include("../cargokit/cmake/cargokit.cmake") +apply_cargokit(${PROJECT_NAME} ../rust convex_flutter "") + +# List of absolute paths to libraries that should be bundled with the plugin. +# This list could contain prebuilt libraries, or libraries created by an +# external build triggered from this build file. +set(convex_flutter_bundled_libraries + "${${PROJECT_NAME}_cargokit_lib}" + PARENT_SCOPE +) diff --git a/third_party/convex_rs/.gitignore b/third_party/convex_rs/.gitignore new file mode 100644 index 00000000..dec8ba25 --- /dev/null +++ b/third_party/convex_rs/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +target/ +convex_local_backend*.sqlite3 +convex_local_storage/ +.DS_Store +# testing 123 diff --git a/third_party/convex_rs/.prettierrc b/third_party/convex_rs/.prettierrc new file mode 100644 index 00000000..473aecc5 --- /dev/null +++ b/third_party/convex_rs/.prettierrc @@ -0,0 +1,2 @@ +proseWrap: "always" +arrowParens: "avoid" diff --git a/third_party/convex_rs/CHANGELOG.md b/third_party/convex_rs/CHANGELOG.md new file mode 100644 index 00000000..da573d9d --- /dev/null +++ b/third_party/convex_rs/CHANGELOG.md @@ -0,0 +1,103 @@ +# 0.10.4 + +- Optimizations to `check_valid_field_name` in `sync_types` +- Fix for memory leak in query subscriptions + (https://github.com/get-convex/convex-rs/issues/15) +- Bump rust-version minimum from 1.80.1 to 1.85 + +# 0.10.3 + +- Fix for incorrect client state on WebSocket reconnect +- New `set_auth_callback` method on `ConvexClient` to allow token refresh on + WebSocket reconnect + +# 0.10.2 + +- Fix for deadlock between client and websocket worker tasks +- Update `tokio` dependency + +# 0.10.1 + +- Bump sync_types version and depend on it + +# 0.10.0 + +- Fix for panic in query subscriptions +- Bump rust-version minimum from 1.71.1 to 1.80.1 + +# 0.9.0 + +- Add `ConvexClientBuilder` pattern for constructing `ConvexClient` +- Add support for `on_state_change` for handling reconnects. +- Bump rust-version minimum from 1.65.0 to 1.71.1 +- Update `url` dependency. + +# 0.8.1 + +Remove native-tls-vendored dependency for tokio-tungstenite. Rely on requested +features instead. + +# 0.8.0 + +- Support for passing through a client_id to ConvexClient +- Dependency upgrades + +# 0.7.0 + +- Several dependency upgrades + +# 0.6.0 + +- Remove support for Set and Map Convex types. These types are deprecated. +- Add comprehensive support for ConvexError with `data` payload as part of the + `FunctionResult` enum. +- Better support for emitting loglines + +# 0.5.0 + +- Prelim support for ConvexError, encoded into an anyhow::Error. Eventual plan + is to expose a separate catchable type, but just getting something out + quickly. PRs accepted! + +# 0.4.0 + +- Expose an alternate cleaner JSON export format on Value. The clean format is + lossy in some cases (eg both integers and strings are encoded as JSON + strings). +- Expose native-tls-vendored feature + +# 0.3.1 + +- Fix compilation with `--features=testing` +- Minor syntactic changes to quickstart + +# 0.3.0 + +- Remove `Value::Id` since document IDs are `Value::String`s for Convex + functions starting from NPM version 0.17 +- Minor improvements to convex_chat_client example +- Minor improvements in convex_sync_types + +# 0.2.0 + +- BUGFIX: Client occasionally used to get stuck in a hot loop after network + disconnect. +- Tweak backoff params for better performance across network disconnect. +- Minor improvements to convex_chat_client example +- Minor fix to running tests +- Bump tokio-tungstenite to 0.18 +- Minor improvements in convex_sync_types + +# 0.1.2 + +Yanked and re-released as 0.2.0 + +# 0.1.1 + +- Fix race between mutation result and dropping a subscription. +- Minor logging/error message improvements. + +# 0.1.0 + +- Initial release. +- Support for queries, subscriptions, mutations, actions diff --git a/third_party/convex_rs/CONTRIBUTING.md b/third_party/convex_rs/CONTRIBUTING.md new file mode 100644 index 00000000..0d5fd9f7 --- /dev/null +++ b/third_party/convex_rs/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +Contributions are welcome! + +Please share any general questions, feature requests, or product feedback in our +[Convex Discord Community](https://convex.dev/community). We're particularly +excited to see what you build on Convex! + +Please ensure that rust code is formatted with +[cargo fmt](https://github.com/rust-lang/rustfmt) and markdown files are +formatted with [prettier](https://prettier.io/). + +Run tests with + +``` +cargo test -p {crate} +``` + +Convex is a fast moving project developed by a dedicated team. We're excited to +contribute to the community by releasing this code, but we want to manage +expectations as well. + +- We are a small company with a lot of product surface area. +- We value a cohesive developer experience for folks building applications + across all of our languages and platforms. +- We value transparency in how we operate. + +We're excited for community PRs. Be aware we may not get to it for a while. +Smaller PRs that only affect documentation/comments are easier to review and +integrate. For any larger or more fundamental changes, get in touch with us on +Discord before you put in too much work to see if it's consistent with our short +term plan. We think carefully about how our APIs contribute to a cohesive +product, so chatting up front goes a long way. + +# Docs contributions + +Docs are located in the `npm-packages/docs` directory. See the README there for +more info. diff --git a/third_party/convex_rs/Cargo.lock b/third_party/convex_rs/Cargo.lock new file mode 100644 index 00000000..13d25da1 --- /dev/null +++ b/third_party/convex_rs/Cargo.lock @@ -0,0 +1,2145 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "archery" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eae2ed21cd55021f05707a807a5fc85695dafb98832921f6cfa06db67ca5b869" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitmaps" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703642b98a00b3b90513279a8ede3fcfa479c126c5fb46e78f3051522f021403" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.48.0", +] + +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convex" +version = "0.10.4" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.13.1", + "bytes", + "colored", + "convex_sync_types", + "dotenvy", + "futures", + "imbl", + "maplit", + "parking_lot", + "pretty_assertions", + "proptest", + "proptest-derive", + "rand 0.9.0", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "convex_sync_types" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba8235188b091cc50205a436bf6505cb386be208263fecdb4b62bd2b3c90a0a" +dependencies = [ + "anyhow", + "base64 0.13.1", + "bytes", + "derive_more", + "headers", + "pretty_assertions", + "proptest", + "proptest-derive", + "rand 0.9.0", + "serde", + "serde_json", + "strum", + "uuid", +] + +[[package]] +name = "core-foundation" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280a9f2d8b3a38871a3c8a46fb80db65e5e5ed97da80c4d08bf27fb63e35e181" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "data-encoding" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d8666cb01533c39dde32bcbab8e227b4ed6679b2c925eba05feabea39508fb" + +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn 2.0.108", + "unicode-xid", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "headers" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322106e6bd0cba2d5ead589ddb8150a13d7c4217cf80d7c4f682ca994ccc6aa9" +dependencies = [ + "base64 0.21.7", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "imbl" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e525189e5f603908d0c6e0d402cb5de9c4b2c8866151fabc4ebd771ed2630a2e" +dependencies = [ + "archery", + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.9.1", + "rand_xoshiro", + "version_check", + "wide", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libm" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348108ab3fba42ec82ff6e9564fc4ca0247bdccdc68dd8af9764bbc79c3c8ffb" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.8.2", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-src" +version = "300.2.3+3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cff92b6f71555b61bb9315f7c64da3ca43d87531622120fea0195fc761b4843" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "pretty_assertions" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +dependencies = [ + "ctor", + "diff", + "output_vt100", + "yansi", +] + +[[package]] +name = "proc-macro2" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e0f6df8eaa422d97d72edcd152e1451618fed47fabbdbd5a8864167b1d4aff7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c2511913b88df1637da85cc8d96ec8e43a3f8bb8ccb71ee1ac240d6f3df58d" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "lazy_static", + "num-traits", + "rand 0.8.5", + "rand_chacha 0.3.1", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff7ff745a347b87471d859a377a9a404361e7efc2a971d73424a6d183c0fc77" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.1", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_core" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88e0da7a2c97baa202165137c158d0a2e824ac465d13d81046727b34cb247d3" +dependencies = [ + "getrandom 0.3.1", + "zerocopy", +] + +[[package]] +name = "rand_xorshift" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.2.0", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "security-framework" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a332be01508d814fed64bf28f798a146d73792121129962fdf335bb3c49a4254" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.3", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.108", +] + +[[package]] +name = "subtle" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tempfile" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.2.15", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.0", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom 0.2.15", + "serde", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +dependencies = [ + "libc", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde3bb8c68a8f3f1ed4ac9221aad6b10cece3e60a8e2ea54a6a2dec806d0084c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea57037071898bf96a6da35fd626f4f27e9cee3ead2a6c703cf09d472b2e700" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] diff --git a/third_party/convex_rs/Cargo.toml b/third_party/convex_rs/Cargo.toml new file mode 100644 index 00000000..41e09222 --- /dev/null +++ b/third_party/convex_rs/Cargo.toml @@ -0,0 +1,180 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2021" +rust-version = "1.85" +name = "convex" +version = "0.10.4" +authors = ["Convex, Inc. "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Client library for Convex (convex.dev)" +homepage = "https://www.convex.dev/" +readme = "README.md" +license = "Apache-2.0" +repository = "https://github.com/get-convex/convex-rs" +resolver = "2" + +[features] +default = ["native-tls-vendored"] +native-tls = ["tokio-tungstenite/native-tls"] +native-tls-vendored = ["tokio-tungstenite/native-tls-vendored"] +rustls-tls-native-roots = ["tokio-tungstenite/rustls-tls-native-roots"] +rustls-tls-webpki-roots = ["tokio-tungstenite/rustls-tls-webpki-roots"] +testing = [ + "convex_sync_types/testing", + "proptest", + "proptest-derive", + "parking_lot", +] + +[lib] +name = "convex" +path = "src/lib.rs" + +[[example]] +name = "convex_chat_client" +path = "examples/convex_chat_client.rs" + +[[example]] +name = "quickstart" +path = "examples/quickstart/main.rs" + +[dependencies.anyhow] +version = "1" + +[dependencies.async-trait] +version = "0.1" + +[dependencies.base64] +version = "0.13" + +[dependencies.bytes] +version = "1.6.0" + +[dependencies.convex_sync_types] +version = "=0.10.4" + +[dependencies.futures] +version = "0.3" + +[dependencies.imbl] +version = "7.0.0" + +[dependencies.parking_lot] +version = "0.12" +features = ["hardware-lock-elision"] +optional = true + +[dependencies.proptest] +version = "1" +optional = true + +[dependencies.proptest-derive] +version = "0.5.0" +optional = true + +[dependencies.rand] +version = "0.9" + +[dependencies.serde_json] +version = "1" +features = [ + "float_roundtrip", + "preserve_order", + "raw_value", +] + +[dependencies.thiserror] +version = "2" + +[dependencies.tokio] +version = "1.47.1" +features = ["full"] + +[dependencies.tokio-stream] +version = "0.1" +features = [ + "io-util", + "sync", +] + +[dependencies.tokio-tungstenite] +version = "0.28.0" +features = ["url"] + +[dependencies.tracing] +version = "0.1" + +[dependencies.url] +version = "2.5.4" + +[dependencies.uuid] +version = "1.6" +features = [ + "serde", + "v4", +] + +[dev-dependencies.colored] +version = "3" + +[dev-dependencies.convex_sync_types] +version = "=0.10.4" +features = ["testing"] + +[dev-dependencies.dotenvy] +version = "0.15.7" + +[dev-dependencies.maplit] +version = "1" + +[dev-dependencies.parking_lot] +version = "0.12" +features = ["hardware-lock-elision"] + +[dev-dependencies.pretty_assertions] +version = "1" + +[dev-dependencies.proptest] +version = "1" + +[dev-dependencies.proptest-derive] +version = "0.5.0" + +[dev-dependencies.tracing-subscriber] +version = "0.3.17" +features = ["env-filter"] + +[lints.clippy] +await_holding_lock = "warn" +await_holding_refcell_ref = "warn" +large_enum_variant = "allow" +manual_is_multiple_of = "allow" +manual_map = "allow" +new_without_default = "allow" +op_ref = "allow" +ptr_arg = "allow" +result_large_err = "allow" +single_match = "allow" +too_many_arguments = "allow" +type_complexity = "allow" +upper_case_acronyms = "allow" +useless_format = "allow" +useless_vec = "allow" + +[lints.rust] +unused_extern_crates = "warn" diff --git a/third_party/convex_rs/Cargo.toml.orig b/third_party/convex_rs/Cargo.toml.orig new file mode 100644 index 00000000..2cfc9c31 --- /dev/null +++ b/third_party/convex_rs/Cargo.toml.orig @@ -0,0 +1,71 @@ +[package] +name = "convex" +description = "Client library for Convex (convex.dev)" +authors = [ "Convex, Inc. " ] +version = "0.10.4" +edition = "2021" +rust-version = "1.85" +resolver = "2" +license = "Apache-2.0" +repository = "https://github.com/get-convex/convex-rs" +homepage = "https://www.convex.dev/" + +[features] +default = [ "native-tls-vendored" ] +native-tls = [ "tokio-tungstenite/native-tls" ] +native-tls-vendored = [ "tokio-tungstenite/native-tls-vendored" ] +rustls-tls-native-roots = [ "tokio-tungstenite/rustls-tls-native-roots" ] +rustls-tls-webpki-roots = [ "tokio-tungstenite/rustls-tls-webpki-roots" ] +testing = [ "convex_sync_types/testing", "proptest", "proptest-derive", "parking_lot" ] + +[dependencies] +anyhow = { version = "1" } +async-trait = { version = "0.1" } +base64 = { version = "0.13" } +bytes = { version = "1.6.0" } +convex_sync_types = { path = "./sync_types", version = "=0.10.4" } +futures = { version = "0.3" } +imbl = { version = "7.0.0" } +parking_lot = { optional = true, version = "0.12", features = [ "hardware-lock-elision" ] } +proptest = { optional = true, version = "1" } +proptest-derive = { optional = true, version = "0.5.0" } +rand = { version = "0.9" } +serde_json = { features = [ "float_roundtrip", "preserve_order", "raw_value" ], version = "1" } +thiserror = { version = "2" } +tokio = { features = [ "full" ], version = "1.47.1" } +tokio-stream = { features = [ "io-util", "sync" ], version = "0.1" } +tokio-tungstenite = { features = [ "url" ], version = "0.28.0" } +tracing = { version = "0.1" } +url = { version = "2.5.4" } +uuid = { features = [ "serde", "v4" ], version = "1.6" } + +[dev-dependencies] +colored = { version = "3" } +convex_sync_types = { path = "./sync_types", version = "=0.10.4", features = [ "testing" ] } +dotenvy = { version = "0.15.7" } +maplit = { version = "1" } +parking_lot = { version = "0.12", features = [ "hardware-lock-elision" ] } +pretty_assertions = { version = "1" } +proptest = { version = "1" } +proptest-derive = { version = "0.5.0" } +tracing-subscriber = { features = [ "env-filter" ], version = "0.3.17" } + +[lints.rust] +unused_extern_crates = "warn" + +[lints.clippy] +await_holding_lock = "warn" +await_holding_refcell_ref = "warn" +large_enum_variant = "allow" +manual_is_multiple_of = "allow" +manual_map = "allow" +new_without_default = "allow" +op_ref = "allow" +ptr_arg = "allow" +result_large_err = "allow" +single_match = "allow" +too_many_arguments = "allow" +type_complexity = "allow" +upper_case_acronyms = "allow" +useless_format = "allow" +useless_vec = "allow" diff --git a/third_party/convex_rs/ICARUS_PATCH.md b/third_party/convex_rs/ICARUS_PATCH.md new file mode 100644 index 00000000..78ad4956 --- /dev/null +++ b/third_party/convex_rs/ICARUS_PATCH.md @@ -0,0 +1,40 @@ +# Icarus convex-rs patch + +Source: the published `convex` 0.10.4 crate. + +Icarus adds a public `reconnect_now` request that tells the existing worker to +restart its WebSocket and replay current auth, subscriptions, and in-flight +mutations. The upstream client already owns that recovery path, but 0.10.4 does +not expose a way for a host application to trigger it. + +The local `convex_flutter` package uses this method for its documented manual +reconnect API. This keeps the runtime fault symmetric with Dartvex instead of +mistaking an authenticated health query for a socket reconnect. + +Icarus also separates server auth rejection from generic network failure in the +client worker. Auth rejection and the protocol-close errors caused by its old +socket retry the stored refresh callback every 250 ms. Buffered transitions from +the rejected socket cannot end recovery: the client first observes that the +callback produced a different token, then requires a server transition or +function response on that attempt. Other failures retain the upstream +randomized exponential backoff. This prevents a fresh token from sitting behind +a network backoff of up to 15 seconds without creating a hot reconnect loop +while the auth provider refreshes. + +The reconnect request carries that auth-recovery state to the WebSocket worker. +The client worker already supplies the 250 ms pacing, so the WebSocket worker +resets and skips its independent network backoff for those attempts. Without +that coordination, the two workers can each back off the same rejected socket +and still strand a fresh token for up to 15 seconds. + +Before a reconnect, the worker drains responses buffered by the connection it +is replacing. Auth rejection can otherwise be followed by a stale socket-close +failure during the retry delay; replaying that old failure on the new connection +starts a second backoff and can also apply old query-set transitions after the +client has rebuilt its versions. + +WebSocket reconnects now retain one session ID for the lifetime of the client +and increment `connection_count` after every successful socket open, including +client-requested reconnects. The published Rust client created a new session ID +for every socket and did not advance the count on clean reconnects, unlike the +Convex sync protocol's client-lifetime session and monotonic connection model. diff --git a/.cursor/skills/frontend-design/LICENSE.txt b/third_party/convex_rs/LICENSE similarity index 89% rename from .cursor/skills/frontend-design/LICENSE.txt rename to third_party/convex_rs/LICENSE index f433b1a5..b615b0fc 100644 --- a/.cursor/skills/frontend-design/LICENSE.txt +++ b/third_party/convex_rs/LICENSE @@ -175,3 +175,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Convex, Inc. + + 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. diff --git a/third_party/convex_rs/README.md b/third_party/convex_rs/README.md new file mode 100644 index 00000000..cfbbce12 --- /dev/null +++ b/third_party/convex_rs/README.md @@ -0,0 +1,63 @@ +# Convex + +The official Rust client for [Convex](https://convex.dev/). + +![GitHub](https://img.shields.io/github/license/get-convex/convex-rs) + +Convex is the backend application platform with everything you need to build +your product. + +This Rust client can write and read data from a Convex backend with queries, +mutations, and actions. Get up and running at +[docs.convex.dev](https://docs.convex.dev/introduction/). + +[Join us on Discord](https://www.convex.dev/community) to share what you're +working on or get your questions answered. + +# Installation + +Add the following to your `Cargo.toml` file + +```toml +[dependencies] +convex = "*" +``` + +# Example + +```rust +let mut client = ConvexClient::new(DEPLOYMENT_URL).await?; +let mut subscription = client.subscribe("getCounter", vec![]).await?; +while let Some(new_val) = subscription.next().await { + println!("Counter updated to {new_val:?}"); +} +``` + +# Documentation + +Check out the full convex documentation at +[docs.convex.dev](https://docs.convex.dev/introduction/) The rust API docs are +available on [docs.rs](https://docs.rs/convex/latest/convex/) + +# MSRV + +The Convex rust client works on stable rust 1.71.1 and higher. It also works on +nightly. + +# Debug Logging + +The Convex Rust Client uses the +[tracing](https://docs.rs/tracing/latest/tracing/) crate for logging. One common +way of initializing is via `tracing_subscriber`. Then, you can see debug logging +by running your program with `RUST_LOG=convex=debug`. + +```rust +tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); +``` + +By default, this will emit all logs, including internal logs from the client. +Logs from your Convex backend will show up under the `convex_logs` target at +Level=DEBUG. If you want to isolate just those logs, please refer to the +[tracing_subscriber documentation](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/layer/index.html#filtering-with-layers). diff --git a/third_party/convex_rs/examples/convex_chat_client.rs b/third_party/convex_rs/examples/convex_chat_client.rs new file mode 100644 index 00000000..ec697a8d --- /dev/null +++ b/third_party/convex_rs/examples/convex_chat_client.rs @@ -0,0 +1,189 @@ +//! A client for the Convex tutorial chat app. +//! +//! Please run this Convex Chat client from an initialized Convex project. +//! Check out the https://docs.convex.dev/get-started - to get started. +//! +//! Once you've initialized a Convex project with the tutorial, run this +//! demo from inside the project's working directory. +//! +//! For example: +//! cd /path/to/convex-rs +//! cargo build --example convex_chat_client +//! cd /path/to/convex-demos/tutorial +//! /path/to/convex-rs/target/debug/examples/convex_chat_client + +use std::env; + +use colored::Colorize; +use convex::{ + ConvexClient, + FunctionResult, + Value, +}; +use futures::{ + pin_mut, + select_biased, + FutureExt, + StreamExt, +}; +use maplit::btreemap; +use tokio::sync::oneshot; + +const SETUP_MSG: &str = r" +Please run this Convex Chat client from an initialized Convex project. +Check out the https://docs.convex.dev/get-started - to get started. + +Once you've initialized a Convex project with the tutorial, run this +demo from inside the project's working directory. + +For example: +cd /path/to/convex-rs +cargo build --example convex_chat_clientt +cd /path/to/convex-demos/tutorial +/path/to/convex-rs/target/debug/examples/convex_chat_client + +"; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + // Load the tutorial's VITE_CONVEX_URL from the env file + dotenvy::from_filename(".env.local").ok(); + dotenvy::dotenv().ok(); + let Ok(deployment_url) = env::var("VITE_CONVEX_URL") else { + panic!("{SETUP_MSG}"); + }; + println!("Connecting to {deployment_url}"); + + // Client code used in thread #1 + let mut client = ConvexClient::new(&deployment_url).await?; + + // Client code used in thread #2 + let mut client_ = client.clone(); + + println!("{}", format!("Hi! What's your name?").red().bold()); + let mut sender = readline()?; + if sender.is_empty() { + sender = String::from("Anonymous Person"); + } + + let sender_clone = sender.clone(); + + // Thread listening for new messages (use_query demo) + let (cancel_sender, cancel_receiver) = oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + let mut subscription = client + .subscribe("messages:list", btreemap! {}) + .await + .unwrap(); + + let cancel_fut = cancel_receiver.fuse(); + pin_mut!(cancel_fut); + loop { + select_biased! { + new_val = subscription.next().fuse() => { + let new_val = new_val.expect("Client dropped prematurely"); + println!( + "{}", + format!("---------------- Message History ----------------").yellow() + ); + if let FunctionResult::Value(Value::Array(array)) = new_val { + for item in array { + if let Value::Object(obj) = item { + if let Some(Value::String(str)) = obj.get("body") { + let author = match obj.get("author") { + Some(Value::String(name)) => name, + _ => "Anonymous Author", + }; + let author_string = if author == &sender_clone { + format!("{author}").yellow().bold() + } else { + format!("{author}").red().bold() + }; + println!("{author_string}: {str:?}"); + } + } + } + } + println!( + "{}", + format!("-------------- End Message History --------------").yellow() + ); + }, + _ = cancel_fut => { + break + }, + } + } + println!("Message listener closed"); + }); + + // Loop for sending messages + loop { + let line = readline()?; + let line = line.trim(); + if line.is_empty() { + continue; + } + + if line == "quit" || line == "exit" { + println!( + "{}", + format!("------------- Exiting Convex Demo -------------").blue() + ); + break; + } + + println!("{}", format!("Sending a message").yellow().bold()); + let result = client_ + .mutation( + "messages:send", + btreemap! { + "body".to_string() => line.into(), + "author".to_string() => sender.clone().into(), + }, + ) + .await?; + match result { + FunctionResult::Value(Value::Null) => { + println!("{}.", format!("Message sent").green().bold()); + }, + FunctionResult::Value(v) => { + println!( + "{}", + format!("Unexpected non-null result from messages:send {v:?}") + .red() + .bold() + ); + }, + FunctionResult::ErrorMessage(err) => { + println!("{}.", err.red().bold()); + }, + FunctionResult::ConvexError(err) => { + println!("{err:?}"); + }, + }; + } + + cancel_sender + .send(()) + .expect("Failed to send termination signal"); + handle.await?; + + Ok(()) +} + +fn readline() -> anyhow::Result { + let mut buffer = String::new(); + std::io::stdin().read_line(&mut buffer)?; + if buffer.ends_with('\n') { + buffer.pop(); + if buffer.ends_with('\r') { + buffer.pop(); + } + } + Ok(buffer) +} diff --git a/third_party/convex_rs/examples/quickstart/convex/tasks.ts b/third_party/convex_rs/examples/quickstart/convex/tasks.ts new file mode 100644 index 00000000..31159f01 --- /dev/null +++ b/third_party/convex_rs/examples/quickstart/convex/tasks.ts @@ -0,0 +1,7 @@ +import { query } from "./_generated/server"; + +export const get = query({ + handler: async ({ db }) => { + return await db.query("tasks").collect(); + }, +}); diff --git a/third_party/convex_rs/examples/quickstart/main.rs b/third_party/convex_rs/examples/quickstart/main.rs new file mode 100644 index 00000000..9e5949a0 --- /dev/null +++ b/third_party/convex_rs/examples/quickstart/main.rs @@ -0,0 +1,18 @@ +use std::{ + collections::BTreeMap, + env, +}; + +use convex::ConvexClient; + +#[tokio::main] +async fn main() { + dotenvy::from_filename(".env.local").ok(); + dotenvy::dotenv().ok(); + + let deployment_url = env::var("CONVEX_URL").unwrap(); + + let mut client = ConvexClient::new(&deployment_url).await.unwrap(); + let result = client.query("tasks:get", BTreeMap::new()).await.unwrap(); + println!("{result:#?}"); +} diff --git a/third_party/convex_rs/examples/quickstart/package-lock.json b/third_party/convex_rs/examples/quickstart/package-lock.json new file mode 100644 index 00000000..be5de5f4 --- /dev/null +++ b/third_party/convex_rs/examples/quickstart/package-lock.json @@ -0,0 +1,540 @@ +{ + "name": "convex-rust-quickstart", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "convex-rust-quickstart", + "dependencies": { + "convex": "^1.34.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", + "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", + "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", + "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", + "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", + "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", + "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", + "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", + "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", + "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", + "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", + "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", + "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", + "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", + "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", + "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", + "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", + "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", + "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", + "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", + "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", + "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", + "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", + "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", + "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", + "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", + "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/convex": { + "version": "1.34.1", + "resolved": "https://registry.npmjs.org/convex/-/convex-1.34.1.tgz", + "integrity": "sha512-ooyFnZVVq0u6b5zt0Ptq8QB2ixhf/2vXe+PIcUtdtrs0lq/TwpkmmruHdqkFmWgMd6N+Tmfy8AGkz6QnZUYZBA==", + "license": "Apache-2.0", + "dependencies": { + "esbuild": "0.27.0", + "prettier": "^3.0.0", + "ws": "8.18.0" + }, + "bin": { + "convex": "bin/main.js" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=7.0.0" + }, + "peerDependencies": { + "@auth0/auth0-react": "^2.0.1", + "@clerk/clerk-react": "^4.12.8 || ^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@auth0/auth0-react": { + "optional": true + }, + "@clerk/clerk-react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", + "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.0", + "@esbuild/android-arm": "0.27.0", + "@esbuild/android-arm64": "0.27.0", + "@esbuild/android-x64": "0.27.0", + "@esbuild/darwin-arm64": "0.27.0", + "@esbuild/darwin-x64": "0.27.0", + "@esbuild/freebsd-arm64": "0.27.0", + "@esbuild/freebsd-x64": "0.27.0", + "@esbuild/linux-arm": "0.27.0", + "@esbuild/linux-arm64": "0.27.0", + "@esbuild/linux-ia32": "0.27.0", + "@esbuild/linux-loong64": "0.27.0", + "@esbuild/linux-mips64el": "0.27.0", + "@esbuild/linux-ppc64": "0.27.0", + "@esbuild/linux-riscv64": "0.27.0", + "@esbuild/linux-s390x": "0.27.0", + "@esbuild/linux-x64": "0.27.0", + "@esbuild/netbsd-arm64": "0.27.0", + "@esbuild/netbsd-x64": "0.27.0", + "@esbuild/openbsd-arm64": "0.27.0", + "@esbuild/openbsd-x64": "0.27.0", + "@esbuild/openharmony-arm64": "0.27.0", + "@esbuild/sunos-x64": "0.27.0", + "@esbuild/win32-arm64": "0.27.0", + "@esbuild/win32-ia32": "0.27.0", + "@esbuild/win32-x64": "0.27.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/third_party/convex_rs/examples/quickstart/package.json b/third_party/convex_rs/examples/quickstart/package.json new file mode 100644 index 00000000..4015a3c7 --- /dev/null +++ b/third_party/convex_rs/examples/quickstart/package.json @@ -0,0 +1,7 @@ +{ + "name": "convex-rust-quickstart", + "private": true, + "dependencies": { + "convex": "^1.34.1" + } +} diff --git a/third_party/convex_rs/examples/quickstart/sampleData.jsonl b/third_party/convex_rs/examples/quickstart/sampleData.jsonl new file mode 100644 index 00000000..737408ee --- /dev/null +++ b/third_party/convex_rs/examples/quickstart/sampleData.jsonl @@ -0,0 +1,3 @@ +{"text": "Buy groceries", "isCompleted": true} +{"text": "Go for a swim", "isCompleted": true} +{"text": "Integrate Convex", "isCompleted": false} \ No newline at end of file diff --git a/third_party/convex_rs/rust-toolchain b/third_party/convex_rs/rust-toolchain new file mode 100644 index 00000000..2a8b1190 --- /dev/null +++ b/third_party/convex_rs/rust-toolchain @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2026-02-18" diff --git a/third_party/convex_rs/rustfmt.toml b/third_party/convex_rs/rustfmt.toml new file mode 100644 index 00000000..753cfef0 --- /dev/null +++ b/third_party/convex_rs/rustfmt.toml @@ -0,0 +1,16 @@ +use_field_init_shorthand = true +use_try_shorthand = true +match_block_trailing_comma = true + +# Nightly only options: +unstable_features = true +condense_wildcard_suffixes = true +format_strings = true +imports_granularity = "Crate" +reorder_impl_items = true +imports_layout = "Vertical" +group_imports = "StdExternalCrate" +wrap_comments = true +normalize_comments = false +error_on_line_overflow = true +style_edition = "2021" # in 2024 sort order is now case-insensitive, deferring the mass reformat diff --git a/third_party/convex_rs/src/base_client/mod.rs b/third_party/convex_rs/src/base_client/mod.rs new file mode 100644 index 00000000..5226fe3b --- /dev/null +++ b/third_party/convex_rs/src/base_client/mod.rs @@ -0,0 +1,1062 @@ +//! The synchronous state machine for Convex. It's +//! recommended to use the higher level [`ConvexClient`] unless you are building +//! a framework. +//! +//! See docs for [`BaseConvexClient`]. +use std::{ + cmp, + collections::{ + BTreeMap, + BTreeSet, + VecDeque, + }, + future::Future, + pin::Pin, +}; + +use convex_sync_types::{ + types::SerializedArgs, + AuthenticationToken, + CanonicalizedUdfPath, + ClientMessage, + IdentityVersion, + QueryId, + QuerySetModification, + QuerySetVersion, + SessionRequestSeqNumber, + StateModification, + StateVersion, + Timestamp, + UdfPath, +}; +use serde_json::json; +use tokio::sync::oneshot; + +#[cfg(doc)] +use crate::ConvexClient; +use crate::{ + convex_logs, + sync::{ + ReconnectProtocolReason, + ServerMessage, + }, + value::Value, + ConvexError, +}; + +mod request_manager; +use request_manager::{ + RequestId, + RequestManager, +}; +mod query_result; +pub use query_result::{ + FunctionResult, + QueryResults, +}; + +use self::request_manager::RequestType; + +/// A callback that fetches an auth token. The `bool` parameter indicates +/// whether a forced refresh is requested (e.g. on websocket reconnect). +pub type AuthTokenFetcher = Box< + dyn Fn(bool) -> Pin> + Send>> + + Send + + Sync, +>; + +#[derive(Clone, Eq, PartialEq, PartialOrd, Ord, Debug)] +struct QueryToken(String); + +#[derive(Clone, Debug)] +struct LocalQuery { + id: QueryId, + canonicalized_udf_path: CanonicalizedUdfPath, + args: BTreeMap, + num_subscribers: usize, // TODO: remove + /// A unique index value for each subscription to this query. + /// + /// Must be incremented each time a new subscription is added, and never + /// decremented. + subscription_index: usize, +} + +#[derive(Clone, Debug)] +struct Query { + result: FunctionResult, + _udf_path: CanonicalizedUdfPath, + _args: BTreeMap, +} + +/// An identifier for a single subscriber to a query. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, PartialOrd, Ord, Hash)] +#[cfg_attr(test, derive(proptest_derive::Arbitrary))] +pub struct SubscriberId(QueryId, usize); + +impl SubscriberId { + #[cfg(test)] + pub fn query_id(&self) -> QueryId { + self.0 + } +} + +fn serialize_path_and_args(udf_path: UdfPath, args: BTreeMap) -> QueryToken { + let json_path: String = udf_path.canonicalize().into(); + let json_args: serde_json::Value = Value::Array(vec![Value::Object(args)]).into(); + let json = json!({ + "udfPath": json_path, + "args": json_args, + }); + QueryToken(json.to_string()) +} + +#[derive(Default)] +struct LocalSyncState { + next_query_id: QueryId, + query_set_version: QuerySetVersion, + query_set: BTreeMap, + query_id_to_token: BTreeMap, + latest_results: QueryResults, + identity_version: IdentityVersion, + auth_fetcher: Option, + last_auth_token: AuthenticationToken, +} + +impl LocalSyncState { + fn subscribe( + &mut self, + udf_path: UdfPath, + args: BTreeMap, + ) -> (Option, SubscriberId) { + let canonicalized_udf_path = udf_path.clone().canonicalize(); + let query_token = serialize_path_and_args(udf_path.clone(), args.clone()); + + if let Some(existing_entry) = self.query_set.get_mut(&query_token) { + // This is a new subscription to an existing query. + existing_entry.num_subscribers += 1; + existing_entry.subscription_index += 1; + let query_id = existing_entry.id; + let subscription = SubscriberId(query_id, existing_entry.subscription_index); + let prev = self.latest_results.subscribers.insert(subscription); + assert!(prev.is_none(), "INTERNAL BUG: Subscriber ID already taken."); + return (None, subscription); + } + + let query_id = self.next_query_id; + self.next_query_id = QueryId::new(self.next_query_id.get_id() + 1); + let base_version = self.query_set_version; + self.query_set_version += 1; + let new_version = self.query_set_version; + + let add = QuerySetModification::Add(convex_sync_types::Query { + query_id, + udf_path, + args: SerializedArgs::from_args(vec![Value::Object(args.clone()).into()]) + .expect("Could not serialize query arguments"), + journal: None, + component_path: None, + }); + let message = ClientMessage::ModifyQuerySet { + base_version, + new_version, + modifications: vec![add], + }; + + let query = LocalQuery { + id: query_id, + canonicalized_udf_path, + args, + num_subscribers: 1, + subscription_index: 0, + }; + + self.query_set.insert(query_token.clone(), query); + self.query_id_to_token.insert(query_id, query_token.clone()); + let subscription = SubscriberId(query_id, 0); + let prev = self.latest_results.subscribers.insert(subscription); + assert!(prev.is_none(), "INTERNAL BUG: Subscriber ID already taken."); + (Some(message), subscription) + } + + fn remove_subscriber(&mut self, subscriber_id: SubscriberId) -> Option { + let query_id = self + .latest_results + .subscribers + .remove(&subscriber_id) + .expect("INTERNAL BUG: Dropped unknown Subscriber ID") + .0; + let query_token = match self.query_token(query_id) { + None => panic!("INTERNAL BUG: Unknown query id {query_id}"), + Some(t) => t, + }; + let local_query = match self.query_set.get_mut(&query_token) { + None => panic!("INTERNAL BUG: No query found for query token {query_token:?}",), + Some(q) => q, + }; + + // Update local state + if local_query.num_subscribers > 1 { + local_query.num_subscribers -= 1; + return None; + } + self.query_set.remove(&query_token); + self.query_id_to_token.remove(&query_id); + self.latest_results.results.remove(&query_id); + + let base_version = self.query_set_version; + self.query_set_version += 1; + let new_version = self.query_set_version; + + let remove = QuerySetModification::Remove { query_id }; + Some(ClientMessage::ModifyQuerySet { + base_version, + new_version, + modifications: vec![remove], + }) + } + + fn query_token(&self, query_id: QueryId) -> Option { + self.query_id_to_token.get(&query_id).cloned() + } + + fn query_args(&self, query_id: QueryId) -> Option> { + Some( + self.query_set + .get(&self.query_token(query_id)?)? + .args + .clone(), + ) + } + + fn query_path(&self, query_id: QueryId) -> Option { + Some( + self.query_set + .get(&self.query_token(query_id)?)? + .canonicalized_udf_path + .clone(), + ) + } + + fn authenticate(&mut self, token: AuthenticationToken) -> (ClientMessage, bool) { + let base_version = self.identity_version; + self.identity_version += 1; + let token_changed = token != self.last_auth_token; + self.last_auth_token = token.clone(); + ( + ClientMessage::Authenticate { + base_version, + token, + }, + token_changed, + ) + } + + async fn restart(&mut self) -> (Vec, bool) { + self.identity_version = 0; + let mut messages = Vec::new(); + let mut auth_token_changed = false; + + // If we have a fetcher, get a fresh token for the new connection. + if let Some(ref fetcher) = self.auth_fetcher { + match fetcher(true).await { + Ok(token) if token != AuthenticationToken::None => { + auth_token_changed = token != self.last_auth_token; + self.last_auth_token = token.clone(); + messages.push(ClientMessage::Authenticate { + base_version: 0, + token, + }); + self.identity_version += 1; + }, + Ok(_) => {}, + Err(e) => { + tracing::error!( + "Auth fetcher failed during reconnect: {e:?}. Skipping auth for this \ + reconnect attempt." + ); + }, + } + } + + let mut modifications = Vec::new(); + for local_query in self.query_set.values() { + let add = QuerySetModification::Add(convex_sync_types::Query { + query_id: local_query.id, + udf_path: local_query.canonicalized_udf_path.clone().into(), + args: SerializedArgs::from_args(vec![ + Value::Object(local_query.args.clone()).into() + ]) + .expect("Could not serialize query arguments"), + journal: None, + component_path: None, + }); + modifications.push(add) + } + self.query_set_version = 1; + + messages.push(ClientMessage::ModifyQuerySet { + base_version: 0, + new_version: 1, + modifications, + }); + + (messages, auth_token_changed) + } +} + +#[derive(Debug)] +struct RemoteQuerySet { + version: StateVersion, + remote_query_set: BTreeMap, +} + +impl RemoteQuerySet { + fn new() -> Self { + Self { + version: StateVersion::initial(), + remote_query_set: Default::default(), + } + } + + fn transition(&mut self, transition: ServerMessage) -> Result<(), ReconnectProtocolReason> { + let ServerMessage::Transition { + start_version, + end_version, + modifications, + client_clock_skew: _, + server_ts: _, + } = transition + else { + panic!("not transition"); + }; + if start_version != self.version { + tracing::error!( + "INTERNAL BUG: Protocol Error start_version {:?} is different from self.version \ + {:?}", + start_version, + self.version + ); + return Err("StartVersionMismatch".into()); + } + for modification in modifications { + match modification { + StateModification::QueryUpdated { + query_id, + value, + log_lines, + journal: _, + } => { + for log_line in log_lines.0 { + convex_logs!("{}", log_line); + } + self.remote_query_set + .insert(query_id, FunctionResult::Value(value)); + }, + StateModification::QueryFailed { + query_id, + error_message, + log_lines, + journal: _, + error_data, + } => { + for log_line in log_lines.0 { + convex_logs!("{}", log_line); + } + let function_result = match error_data { + Some(v) => FunctionResult::ConvexError(ConvexError { + message: error_message, + data: v, + }), + None => FunctionResult::ErrorMessage(error_message), + }; + self.remote_query_set.insert(query_id, function_result); + }, + StateModification::QueryRemoved { query_id } => { + self.remote_query_set.remove(&query_id); + }, + } + } + self.version = end_version; + Ok(()) + } +} + +#[derive(Default, Debug)] +struct OptimisticQueryResults { + query_results: BTreeMap, +} + +impl OptimisticQueryResults { + fn ingest_query_results_from_server( + &mut self, + server_query_results: BTreeMap, + _optimistic_updates_to_drop: BTreeSet, + ) -> BTreeMap { + // TODO: use optimistic_updates_to_drop + let old_query_results = self.query_results.clone(); + self.query_results = server_query_results; + let mut changed_queries = BTreeMap::new(); + for (query_id, query) in self.query_results.iter() { + let old_query = old_query_results.get(query_id); + if match old_query { + Some(old_query) => old_query.result != query.result, + None => true, + } { + let result = query.result.clone(); + changed_queries.insert(*query_id, result); + } + } + changed_queries + } + + fn query_result(&self, query_id: QueryId) -> Option { + self.query_results.get(&query_id).map(|q| q.result.clone()) + } +} + +/// The synchronous state machine for the `ConvexClient`. It's recommended to +/// use the higher level `ConvexClient` unless you are building a framework. +/// +/// This struct should be used instead of the `ConvexClient` when you want the +/// ability to build consistent client views. For example, in order to use your +/// own websocket manager or make a client compatible with another language +/// (e.g. Swift or Python). +/// +/// For the latter use case, we strongly recommend you to take a look at the +/// implementation of the `ConvexClient`. The recommended pattern to use an +/// [`BaseConvexClient`] is to create a background thread to manage actions on +/// queries/mutations and incoming websocket connections, and use that to +/// advance the BaseConvexClient's state. +/// +/// ## Managing Convex State +/// The main methods, [`subscribe`](Self::subscribe()), +/// [`unsubscribe`](Self::unsubscribe()), and +/// [`mutation`](Self::mutation()) directly correspond to its +/// equivalent for the external [ConvexClient]. +/// +/// The only different method is [`get_query`](Self::get_query()), which +/// returns the current value for a query given its query id. This method can be +/// used to synchronously request the current value, as opposed to a stream of +/// values in [`subscribe`](crate::ConvexClient::subscribe()). +/// +/// **Note: these methods have the side effect of +/// adding messages to be sent to the server, so you would need to flush all +/// outgoing messages by looping on +/// [`pop_next_message`](Self::pop_next_message()) after each call of the above +/// functions.** +/// +/// ## Watching for consistent updates to queries +/// To watch for consistent changes in query values, you can add the following +/// code to the background thread: +/// ```no_run +/// use convex::base_client::BaseConvexClient; +/// use convex::Value; +/// use convex_sync_types::ServerMessage; +/// +/// fn on_receive_server_message(mut base_client: BaseConvexClient, msg: ServerMessage) { +/// let res = base_client.receive_message(msg).expect("Base client error"); +/// if let Some(latest_result_map) = res { +/// for (subscriber_id, function_result) in latest_result_map.iter() { +/// // Notify components of the updated_value +/// } +/// } +/// } +/// ``` +/// +/// ## Managing Web Socket States +/// To manage websocket messages, use +/// [`receive_message`](Self::receive_message()) (for incoming messages from the +/// server) and [`pop_next_message`](Self::pop_next_message()) (for outgoing +/// messages to send to the server). **The [`BaseConvexClient`] does not +/// send these messages, so you will have to regularly monitor if there are +/// messages to be sent by calling +/// [`pop_next_message`](Self::pop_next_message()).** +/// +/// Additionally, when the websocket reconnects, you should call +/// [`resend_ongoing_queries_mutations`](Self::resend_ongoing_queries_mutations()) and loop on +/// [`pop_next_message`](Self::pop_next_message()) to resend requests to the +/// Server to resubscribe to queries and perform ongoing mutations. +/// +/// #### [`pop_next_message`](Self::pop_next_message()) should be called after the following methods: +/// - [`resend_ongoing_queries_mutations`](Self::resend_ongoing_queries_mutations()) +/// - [`subscribe`](Self::unsubscribe()) +/// - [`unsubscribe`](Self::unsubscribe()) +/// - [`mutation`](Self::unsubscribe()) +pub struct BaseConvexClient { + state: LocalSyncState, + remote_query_set: RemoteQuerySet, + optimistic_query_results: OptimisticQueryResults, + request_manager: RequestManager, + next_request_id: SessionRequestSeqNumber, + outgoing_message_queue: VecDeque, + max_observed_timestamp: Option, +} + +impl BaseConvexClient { + /// Construct a new [`BaseConvexClient`]. + pub fn new() -> Self { + let request_manager = RequestManager::new(); + let state = LocalSyncState::default(); + let remote_query_set = RemoteQuerySet::new(); + let optimistic_query_results: OptimisticQueryResults = Default::default(); + let next_request_id: SessionRequestSeqNumber = 0; + + BaseConvexClient { + request_manager, + state, + remote_query_set, + optimistic_query_results, + next_request_id, + outgoing_message_queue: VecDeque::new(), + max_observed_timestamp: None, + } + } + + /// Update state to be subscribed to a query and add subscription request to + /// the outgoing message queue. + /// + /// After calling this, it is highly recommended to loop on + /// [`pop_next_message`](Self::pop_next_message()) to flush websocket + /// messages to the server. + pub fn subscribe(&mut self, udf_path: UdfPath, args: BTreeMap) -> SubscriberId { + let (modification, subscription) = self.state.subscribe(udf_path, args); + if let Some(modification) = modification { + self.outgoing_message_queue.push_back(modification); + } + subscription + } + + /// Update state to be unsubscribed to a query and add unsubscription + /// request to the outgoing message queue. + /// + /// After calling this, it is highly recommended to loop on + /// [`pop_next_message`](Self::pop_next_message()) to flush websocket + /// messages to the server. + pub fn unsubscribe(&mut self, subscriber_id: SubscriberId) { + let unsubscribe_message = self.state.remove_subscriber(subscriber_id); + + if let Some(message) = unsubscribe_message { + self.outgoing_message_queue.push_back(message); + } + } + + /// Return the local value of a query. + pub fn get_query(&self, query_id: QueryId) -> Option { + self.local_query_result(query_id) + } + + /// Track mutation and add mutation request to the outgoing message queue. + /// + /// After calling this, it is highly recommended to loop on + /// [`pop_next_message`](Self::pop_next_message()) to flush websocket + /// messages to the server. + pub fn mutation( + &mut self, + udf_path: UdfPath, + args: BTreeMap, + ) -> oneshot::Receiver { + let request_id = self.next_request_id; + self.next_request_id = request_id + 1; + tracing::info!("Starting mutation {udf_path} with id {request_id}"); + let message = ClientMessage::Mutation { + request_id, + udf_path, + args: SerializedArgs::from_args(vec![Value::Object(args).into()]) + .expect("Failed to serialize arguments"), + component_path: None, + }; + + let result_receiver = self.request_manager.track_request( + &message, + RequestId::new(request_id), + RequestType::Mutation, + ); + self.outgoing_message_queue.push_back(message); + result_receiver + } + + /// Track action and add action request to the outgoing message queue. + /// + /// After calling this, it is highly recommended to loop on + /// [`pop_next_message`](Self::pop_next_message()) to flush websocket + /// messages to the server. + pub fn action( + &mut self, + udf_path: UdfPath, + args: BTreeMap, + ) -> oneshot::Receiver { + let request_id = self.next_request_id; + self.next_request_id = request_id + 1; + tracing::info!("Starting action {udf_path:?} with id {request_id:?}"); + let message = ClientMessage::Action { + request_id, + udf_path, + args: SerializedArgs::from_args(vec![Value::Object(args).into()]).unwrap(), + component_path: None, + }; + + let result_receiver = self.request_manager.track_request( + &message, + RequestId::new(request_id), + RequestType::Action, + ); + self.outgoing_message_queue.push_back(message); + result_receiver + } + + /// Store (or clear) an auth token fetcher callback and update auth state. + /// + /// When a fetcher is provided it is invoked immediately (with + /// `force_refresh=false`) and stored for future reconnects — on each + /// websocket reconnect the fetcher is called again with + /// `force_refresh=true`. + /// + /// When `None` is passed the stored fetcher is cleared and auth is unset. + pub async fn set_auth_fetcher(&mut self, fetcher: Option) -> bool { + let mut auth_token_changed = false; + match fetcher { + Some(fetcher) => { + match fetcher(false).await { + Ok(token) => { + let (message, changed) = self.state.authenticate(token); + auth_token_changed = changed; + self.outgoing_message_queue.push_back(message); + }, + Err(e) => { + tracing::error!("Auth token fetcher failed: {e:?}"); + }, + } + self.state.auth_fetcher = Some(fetcher); + }, + None => { + self.state.auth_fetcher = None; + let (message, changed) = self.state.authenticate(AuthenticationToken::None); + auth_token_changed = changed; + self.outgoing_message_queue.push_back(message); + }, + } + auth_token_changed + } + + /// Pop the next message from the outgoing message queue. + /// + /// Note that this does not *send* the message because the Internal client + /// has no awareness of websockets. After popping the next message, it is + /// the caller's responsibility to actually send it. + pub fn pop_next_message(&mut self) -> Option { + self.outgoing_message_queue.pop_front() + } + + fn observe_timestamp(&mut self, ts: Timestamp) { + if let Some(max_observed_timestamp) = self.max_observed_timestamp { + self.max_observed_timestamp = Some(cmp::max(ts, max_observed_timestamp)); + } else { + self.max_observed_timestamp = Some(ts); + } + } + + /// Returns the maximum timestamp observed by the client. + pub fn max_observed_timestamp(&self) -> Option { + self.max_observed_timestamp + } + + /// Given a message from a Server, update the base state accordingly. + pub fn receive_message( + &mut self, + message: ServerMessage, + ) -> Result, ReconnectProtocolReason> { + match message { + ServerMessage::Transition { end_version, .. } => { + self.observe_timestamp(end_version.ts); + self.remote_query_set.transition(message)?; + let completed_requests = self + .request_manager + .remove_and_notify_completed(end_version.ts); + let changed_query_ids = self.on_query_result_changes(completed_requests)?; + for (id, result) in changed_query_ids { + self.state.latest_results.results.insert(id, result); + } + return Ok(Some(self.state.latest_results.clone())); + }, + ServerMessage::MutationResponse { + request_id, + result, + ts, + log_lines, + } => { + for log_line in log_lines.0 { + convex_logs!("{}", log_line); + } + + if let Some(ts) = ts { + self.observe_timestamp(ts); + } + let request_id = RequestId::new(request_id); + self.request_manager.update_request( + &request_id, + RequestType::Mutation, + result.into(), + ts, + )?; + }, + ServerMessage::AuthError { + error_message, + base_version, + .. + } => { + tracing::error!( + "AuthError: {error_message} for identity version {base_version:?}. Restarting \ + protocol." + ); + return Err(format!( + "AuthError: {error_message} for identity version {base_version:?}" + )); + }, + ServerMessage::FatalError { error_message } => { + tracing::error!("FatalError: {error_message}. Restarting protocol."); + return Err(format!("FatalError: {error_message}")); + }, + ServerMessage::ActionResponse { + request_id, + result, + log_lines, + } => { + for log_line in log_lines.0 { + convex_logs!("{}", log_line); + } + let request_id = RequestId::new(request_id); + self.request_manager.update_request( + &request_id, + RequestType::Action, + result.into(), + None, + )?; + }, + ServerMessage::Ping => { + // Do nothing + }, + ServerMessage::TransitionChunk { .. } => { + // The Rust client should never receive TransitionChunk messages + // as this feature is only enabled for npm clients + return Err("Unexpected TransitionChunk message received".to_string()); + }, + } + Ok(None) + } + + /// Grab a snapshot of the latest query results to all subscribed queries. + pub fn latest_results(&self) -> &QueryResults { + &self.state.latest_results + } + + /// Resend all subscribed queries and ongoing mutations. Should be used once + /// the websocket closes and reconnects. + pub async fn resend_ongoing_queries_mutations(&mut self) -> bool { + // Clear any stale messages from the queue. During reconnection + // retries, messages can accumulate from previous failed attempts + // or from subscription changes made while disconnected. Since + // restart() rebuilds the full query set and resets version + // numbers, any pre-existing messages would have stale versions + // that conflict with the fresh restart messages. + self.outgoing_message_queue.clear(); + + let (state_restart_messages, auth_token_changed) = self.state.restart().await; + let mut ongoing_mutation_messages = self.request_manager.restart(); + + self.remote_query_set = RemoteQuerySet::new(); + for state_restart_message in state_restart_messages { + self.outgoing_message_queue.push_back(state_restart_message); + } + self.outgoing_message_queue + .append(&mut ongoing_mutation_messages); + auth_token_changed + } + + fn on_query_result_changes( + &mut self, + completed_requests: BTreeSet, + ) -> Result, ReconnectProtocolReason> { + let remote_query_results = &self.remote_query_set.remote_query_set; + let mut query_id_to_value = BTreeMap::new(); + for (query_id, result) in remote_query_results.iter() { + let Some(_udf_path) = self.state.query_path(*query_id) else { + // It's possible that we've already unsubscribed to this query but + // the server hasn't learned about that yet. If so, ignore this one. + continue; + }; + let _args = self + .state + .query_args(*query_id) + .expect("INTERNAL BUG: Query args exist, but not query path."); + query_id_to_value.insert( + *query_id, + Query { + result: result.clone(), + _udf_path, + _args, + }, + ); + } + Ok(self + .optimistic_query_results + .ingest_query_results_from_server(query_id_to_value, completed_requests)) + } + + fn local_query_result(&self, query_id: QueryId) -> Option { + self.optimistic_query_results.query_result(query_id) + } +} + +/// Macro used for piping UDF logs to a custom formatter that exposes +/// just the log content, without any additional Rust metadata. +#[macro_export] +macro_rules! convex_logs { + (target: $target:expr, $($arg:tt)+) => { + tracing::event!(target: "convex_logs", tracing::Level::DEBUG, $($arg)+); + // Additional custom behavior can be added here + }; + ($($arg:tt)+) => { + tracing::event!(target: "convex_logs", tracing::Level::DEBUG, $($arg)+); + // Additional custom behavior can be added here + }; +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use convex_sync_types::{ + AuthenticationToken, + ClientMessage, + LogLinesMessage, + QuerySetVersion, + UdfPath, + }; + use maplit::btreemap; + + use super::*; + + /// Simulates the server-side version tracking from + /// `sync::state::SyncState::modify_query_set`. Returns Err with the + /// same message the server produces when versions don't match. + fn simulate_server_version_check(messages: &[ClientMessage]) -> Result<(), String> { + let mut query_set_version: QuerySetVersion = 0; + for msg in messages { + if let ClientMessage::ModifyQuerySet { + base_version, + new_version, + .. + } = msg + { + if *base_version != query_set_version { + return Err(format!( + "Base version {base_version} passed up doesn't match the current version \ + {query_set_version}" + )); + } + query_set_version = *new_version; + } + } + Ok(()) + } + + /// Reproduces the bug where repeated reconnection attempts accumulate + /// stale messages in the outgoing queue, causing the server to reject + /// messages with "Base version 0 passed up doesn't match the current + /// version 1". + /// + /// In the real client, this happens when `communicate()` is interrupted + /// by a `ProtocolResponse::Failure` mid-drain (e.g. the WebSocket + /// connection attempt fails). The first message is popped and sent to + /// the WebSocket worker channel, but remaining messages stay in the + /// queue. When `resend_ongoing_queries_mutations()` appends fresh + /// restart messages, the stale leftovers cause version conflicts. + #[tokio::test] + async fn test_reconnect_does_not_send_duplicate_version_messages() { + let mut client = BaseConvexClient::new(); + + // Authenticated client with one active subscription. + client + .set_auth_fetcher(Some(Box::new(|_force_refetch| { + Box::pin(async { Ok(AuthenticationToken::User("test-token".into())) }) + }))) + .await; + let udf = UdfPath::from_str("some:query").unwrap(); + client.subscribe(udf, btreemap! {}); + + // Drain initial messages (successfully sent while connected). + while client.pop_next_message().is_some() {} + + // --- Connection drops, first reconnect attempt --- + assert!(!client.resend_ongoing_queries_mutations().await); + + // Simulate partial drain: the first message (Authenticate) was + // popped and handed to the WebSocket layer, but the connection + // failed before the second message (ModifyQuerySet) could be sent. + let _ = client.pop_next_message(); + + // --- Connection still down, second reconnect attempt --- + assert!(!client.resend_ongoing_queries_mutations().await); + + // Connection finally succeeds — all queued messages are flushed. + let mut messages = vec![]; + while let Some(msg) = client.pop_next_message() { + messages.push(msg); + } + + // The server tracks query set versions sequentially and rejects + // any message whose base_version doesn't match its current state + // (sync::state::SyncState::modify_query_set). Without the fix, + // the stale ModifyQuerySet{base_version:0} from the first attempt + // is still in the queue, followed by the second attempt's + // ModifyQuerySet{base_version:0}. + simulate_server_version_check(&messages) + .expect("Server would reject these messages with a FatalError"); + } + + #[tokio::test] + async fn test_reconnect_path_requests_refreshed_token() { + let mut client = BaseConvexClient::new(); + + // Authenticated client with one active subscription. + client + .set_auth_fetcher(Some(Box::new(|force_refetch| { + Box::pin(async move { + if force_refetch { + // A fake refreshed token. + Ok(AuthenticationToken::User("refetched-token".into())) + } else { + Ok(AuthenticationToken::User("original-token".into())) + } + }) + }))) + .await; + let udf = UdfPath::from_str("some:query").unwrap(); + client.subscribe(udf, btreemap! {}); + + // Drain initial messages (successfully sent while connected). + while client.pop_next_message().is_some() {} + + // --- Connection drops, reconnect attempt --- + assert!(client.resend_ongoing_queries_mutations().await); + + // A fresh authentication attempt should have been initiated, with a new token. + assert_eq!( + client + .pop_next_message() + .expect("Expected an authentication message."), + ClientMessage::Authenticate { + base_version: 0, + token: AuthenticationToken::User("refetched-token".into()), + } + ); + } + + fn drain_add_message(client: &mut BaseConvexClient) -> QueryId { + match client.pop_next_message() { + Some(ClientMessage::ModifyQuerySet { modifications, .. }) => { + let [QuerySetModification::Add(query)] = modifications.as_slice() else { + panic!("expected a single add modification, got {modifications:?}"); + }; + query.query_id + }, + other => panic!("expected add query message, got {other:?}"), + } + } + + fn drain_remove_message(client: &mut BaseConvexClient) -> QueryId { + match client.pop_next_message() { + Some(ClientMessage::ModifyQuerySet { modifications, .. }) => { + let [QuerySetModification::Remove { query_id }] = modifications.as_slice() else { + panic!("expected a single remove modification, got {modifications:?}"); + }; + *query_id + }, + other => panic!("expected remove query message, got {other:?}"), + } + } + + fn apply_query_update( + client: &mut BaseConvexClient, + version: &mut StateVersion, + query_id: QueryId, + value: Value, + ) { + let end_version = StateVersion { + ts: version.ts.succ().expect("timestamp overflow in test"), + ..*version + }; + let transition = ServerMessage::Transition { + start_version: *version, + end_version, + modifications: vec![StateModification::QueryUpdated { + query_id, + value, + log_lines: LogLinesMessage(vec![]), + journal: None, + }], + client_clock_skew: None, + server_ts: None, + }; + + let latest_results = client + .receive_message(transition) + .expect("transition should be accepted"); + assert!( + latest_results.is_some(), + "query update should publish results" + ); + *version = end_version; + } + + #[test] + fn test_final_unsubscribe_removes_cached_query_result() { + let mut client = BaseConvexClient::new(); + let mut version = StateVersion::initial(); + // Add a subscriber. + let subscriber_id = client.subscribe("getValue1".parse().unwrap(), BTreeMap::new()); + let query_id = drain_add_message(&mut client); + assert!(client.pop_next_message().is_none()); + + apply_query_update(&mut client, &mut version, query_id, 10.into()); + assert!(client.state.latest_results.results.contains_key(&query_id)); + assert_eq!( + client.latest_results().get(&subscriber_id), + Some(&FunctionResult::Value(10.into())) + ); + + client.unsubscribe(subscriber_id); + + assert_eq!(drain_remove_message(&mut client), query_id); + assert!(client.pop_next_message().is_none()); + + // The latest_results are gone since there are no more subscribers. + assert!(client.state.latest_results.subscribers.is_empty()); + assert!(!client.state.latest_results.results.contains_key(&query_id)); + } + + #[test] + fn test_cached_query_result_persists_while_subscribers_exist() { + let mut client = BaseConvexClient::new(); + let mut version = StateVersion::initial(); + // Add two subscribers. + let subscriber_a = client.subscribe("getValue1".parse().unwrap(), BTreeMap::new()); + let query_id = drain_add_message(&mut client); + let subscriber_b = client.subscribe("getValue1".parse().unwrap(), BTreeMap::new()); + assert!(client.pop_next_message().is_none()); + + apply_query_update(&mut client, &mut version, query_id, 10.into()); + + // The first subscriber drops. + client.unsubscribe(subscriber_a); + + assert!(client.pop_next_message().is_none()); + + // The latest_results persist since a subscriber still exists. + assert!(client.state.latest_results.results.contains_key(&query_id)); + assert_eq!( + client.latest_results().get(&subscriber_b), + Some(&FunctionResult::Value(10.into())) + ); + } +} diff --git a/third_party/convex_rs/src/base_client/query_result.rs b/third_party/convex_rs/src/base_client/query_result.rs new file mode 100644 index 00000000..4f870150 --- /dev/null +++ b/third_party/convex_rs/src/base_client/query_result.rs @@ -0,0 +1,159 @@ +use convex_sync_types::{ + types::ErrorPayload, + QueryId, +}; +use imbl::{ + OrdMap, + OrdSet, +}; + +use super::SubscriberId; +use crate::{ + ConvexError, + Value, +}; + +/// Result of a Convex function (query/mutation/action). +/// +/// The function returns a Convex value or an error message string. +#[derive(Clone, Eq, PartialEq)] +pub enum FunctionResult { + /// The Convex value returned on a successful run of a Convex function + Value(Value), + /// The error message of a Convex function run that does not complete + /// successfully. + ErrorMessage(String), + /// The error payload of a Convex function run that doesn't complete + /// successfully, with an application-level error. + ConvexError(ConvexError), +} + +impl From>> for FunctionResult { + fn from(result: Result>) -> Self { + match result { + Ok(value) => FunctionResult::Value(value), + Err(ErrorPayload::ErrorData { message, data }) => { + FunctionResult::ConvexError(ConvexError { message, data }) + }, + Err(ErrorPayload::Message(message)) => FunctionResult::ErrorMessage(message), + } + } +} + +impl From for Result> { + fn from(result: FunctionResult) -> Self { + match result { + FunctionResult::Value(value) => Ok(value), + FunctionResult::ErrorMessage(error) => Err(ErrorPayload::Message(error)), + FunctionResult::ConvexError(error) => Err(ErrorPayload::ErrorData { + message: error.message, + data: error.data, + }), + } + } +} + +impl std::fmt::Debug for FunctionResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FunctionResult::Value(value) => f.debug_tuple("Value").field(value).finish(), + FunctionResult::ErrorMessage(error) => write!(f, "{error}"), + FunctionResult::ConvexError(error) => { + f.debug_tuple("ConvexError").field(error).finish() + }, + } + } +} + +/// A mapping from [`SubscriberId`] to its current result [`FunctionResult`] +/// for each actively subscribed query. +#[derive(Clone, Default, Debug)] +pub struct QueryResults { + pub(super) results: OrdMap, + pub(super) subscribers: OrdSet, +} + +impl QueryResults { + /// Get the [`FunctionResult`] for the given [`SubscriberId`] + pub fn get(&self, subscriber_id: &SubscriberId) -> Option<&FunctionResult> { + if !self.subscribers.contains(subscriber_id) { + return None; + }; + self.results.get(&subscriber_id.0) + } + + /// Get the size of the map. + pub fn len(&self) -> usize { + self.subscribers.len() + } + + /// Test whether the map is empty. + pub fn is_empty(&self) -> bool { + self.subscribers.is_empty() + } + + /// Get an iterator over the subscriber_id/query_result pairs of the map. + pub fn iter(&self) -> impl Iterator)> { + self.subscribers.iter().map(|s| (s, self.results.get(&s.0))) + } +} + +#[cfg(test)] +mod tests { + use convex_sync_types::QueryId; + use imbl::{ + ordmap, + ordset, + }; + + use crate::{ + base_client::SubscriberId, + FunctionResult, + QueryResults, + Value, + }; + + #[test] + fn test_query_results() { + let q = QueryId::new; + let s = SubscriberId; + + let qr = QueryResults { + results: ordmap! { + q(0) => FunctionResult::Value(Value::Null), + q(1) => FunctionResult::Value(Value::Int64(5)) + }, + subscribers: ordset! { + s(q(0), 0), + s(q(0), 1), + s(q(1), 0), + s(q(2), 0) + }, + }; + assert_eq!( + qr.get(&s(q(0), 0)), + Some(&FunctionResult::Value(Value::Null)) + ); + assert_eq!( + qr.get(&s(q(0), 1)), + Some(&FunctionResult::Value(Value::Null)) + ); + assert_eq!( + qr.get(&s(q(1), 0)), + Some(&FunctionResult::Value(Value::Int64(5))) + ); + assert_eq!(qr.get(&s(q(2), 0)), None,); + assert_eq!(qr.len(), 4); + assert!(!qr.is_empty()); + let v: Vec<_> = qr.iter().collect(); + assert_eq!( + v, + vec![ + (&s(q(0), 0), Some(&FunctionResult::Value(Value::Null))), + (&s(q(0), 1), Some(&FunctionResult::Value(Value::Null))), + (&s(q(1), 0), Some(&FunctionResult::Value(Value::Int64(5)))), + (&s(q(2), 0), None), + ], + ); + } +} diff --git a/third_party/convex_rs/src/base_client/request_manager.rs b/third_party/convex_rs/src/base_client/request_manager.rs new file mode 100644 index 00000000..8c461354 --- /dev/null +++ b/third_party/convex_rs/src/base_client/request_manager.rs @@ -0,0 +1,170 @@ +use std::{ + cmp::Reverse, + collections::{ + BTreeMap, + BTreeSet, + VecDeque, + }, +}; + +use convex_sync_types::{ + ClientMessage, + Timestamp, +}; +use tokio::sync::oneshot; + +use crate::{ + sync::ReconnectProtocolReason, + FunctionResult, +}; + +#[derive(Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Debug)] +pub struct RequestId(u32); +impl RequestId { + pub fn new(id: u32) -> Self { + RequestId(id) + } +} + +#[derive(Copy, Clone, PartialEq, PartialOrd, Ord, Eq)] +pub enum RequestType { + Mutation, + Action, +} + +#[derive(Clone, PartialEq, PartialOrd, Ord, Eq)] +pub enum RequestStatus { + Requested, + Completed, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct Request { + pub id: RequestId, + pub typ: RequestType, + pub status: RequestStatus, + pub ts: Option, + pub value: Option, + pub message: ClientMessage, +} + +impl Request { + pub fn new(id: RequestId, typ: RequestType, message: ClientMessage) -> Self { + Request { + id, + typ, + status: RequestStatus::Requested, + ts: None, + value: None, + message, + } + } + + pub fn update_value(&mut self, value: FunctionResult) { + self.value = Some(value); + } + + pub fn update_timestamp(&mut self, ts: Option) { + self.ts = ts; + } +} + +pub struct RequestManager { + ongoing_requests: BTreeMap)>, +} + +impl RequestManager { + pub fn new() -> Self { + RequestManager { + ongoing_requests: BTreeMap::new(), + } + } + + pub fn update_request( + &mut self, + request_id: &RequestId, + request_type: RequestType, + value: FunctionResult, + ts: Option, + ) -> Result<(), ReconnectProtocolReason> { + let Some((request, _)) = self.ongoing_requests.get_mut(request_id) else { + return Err("Invalid request id from server".to_string()); + }; + if request.typ != request_type { + return Err("Mismatched request type from server".to_string()); + }; + let errored = matches!(value, FunctionResult::ErrorMessage(_)); + request.update_value(value); + request.update_timestamp(ts); + request.status = RequestStatus::Completed; + + // Actions and errored mutations are ok to complete immediately + if request_type == RequestType::Action || errored { + self._remove_and_notify_completed(request_id); + } + Ok(()) + } + + pub fn remove_and_notify_completed(&mut self, ts: Timestamp) -> BTreeSet { + let mut completed_requests = BTreeSet::new(); + for (id, (request, _)) in self.ongoing_requests.iter() { + let mut is_completed = false; + if request.status == RequestStatus::Completed { + is_completed = true; + } + if let Some(request_ts) = request.ts { + if request_ts <= ts { + is_completed = true; + } + } + if is_completed { + completed_requests.insert(*id); + } + } + for id in completed_requests.iter() { + self._remove_and_notify_completed(id); + } + completed_requests + } + + fn _remove_and_notify_completed(&mut self, request_id: &RequestId) { + let (request, sender) = self + .ongoing_requests + .remove(request_id) + .expect("INTERNAL BUG: request_id must be present"); + if let Err(value) = sender.send( + request + .value + .expect("INTERNAL BUG: Value missing on completed request"), + ) { + tracing::info!( + "Request {request_id:?} completed with result {value:?}, but result receiver was \ + dropped" + ); + } + } + + pub fn track_request( + &mut self, + message: &ClientMessage, + request_id: RequestId, + request_type: RequestType, + ) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + let request = Request::new(request_id, request_type, message.clone()); + self.ongoing_requests.insert(request_id, (request, tx)); + rx + } + + pub fn restart(&self) -> VecDeque { + // Sort ongoing requests by timestamp + let mut ordered_requests = Vec::from_iter(self.ongoing_requests.values()); + ordered_requests.sort_by_key(|(req, _)| Reverse(req.ts)); + + let mut messages = VecDeque::new(); + for (request, _) in ordered_requests { + messages.push_back(request.message.clone()); + } + messages + } +} diff --git a/third_party/convex_rs/src/client/mod.rs b/third_party/convex_rs/src/client/mod.rs new file mode 100644 index 00000000..012554e1 --- /dev/null +++ b/third_party/convex_rs/src/client/mod.rs @@ -0,0 +1,1153 @@ +use std::{ + collections::BTreeMap, + convert::Infallible, + future::Future, + pin::Pin, + sync::Arc, +}; + +use convex_sync_types::{ + AuthenticationToken, + UdfPath, + UserIdentityAttributes, +}; +#[cfg(doc)] +use futures::Stream; +use futures::StreamExt; +use tokio::{ + sync::{ + broadcast, + mpsc, + oneshot, + }, + task::JoinHandle, +}; +use tokio_stream::wrappers::BroadcastStream; +use url::Url; + +pub use crate::base_client::AuthTokenFetcher; +#[cfg(doc)] +use crate::SubscriberId; +use crate::{ + base_client::{ + BaseConvexClient, + QueryResults, + }, + client::{ + subscription::{ + QuerySetSubscription, + QuerySubscription, + }, + worker::{ + worker, + ActionRequest, + ClientRequest, + MutationRequest, + SubscribeRequest, + }, + }, + sync::{ + web_socket_manager::WebSocketManager, + SyncProtocol, + WebSocketState, + }, + value::Value, + FunctionResult, +}; + +pub mod subscription; +mod worker; + +const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION"); + +/// An asynchronous client to interact with a specific project to perform +/// mutations and manage query subscriptions using [`tokio`]. +/// +/// The Convex client requires a deployment url, +/// which can be found in the [dashboard](https://dashboard.convex.dev/) settings tab. +/// +/// ```no_run +/// use convex::ConvexClient; +/// use futures::StreamExt; +/// +/// #[tokio::main] +/// async fn main() -> anyhow::Result<()> { +/// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; +/// let mut sub = client.subscribe("listMessages", maplit::btreemap!{}).await?; +/// while let Some(result) = sub.next().await { +/// println!("{result:?}"); +/// } +/// Ok(()) +/// } +/// ``` +/// +/// The [`ConvexClient`] internally holds a connection and a [`tokio`] +/// background task to manage it. It is advised that you create one and +/// **reuse** it. You can safely clone with [`ConvexClient::clone()`] to share +/// the connection and outstanding subscriptions. +/// +/// ## Examples +/// For example code, please refer to the examples directory. +pub struct ConvexClient { + listen_handle: Option>>, + request_sender: mpsc::UnboundedSender, + watch_receiver: broadcast::Receiver, +} + +/// Clone the [`ConvexClient`], sharing the connection and outstanding +/// subscriptions. +impl Clone for ConvexClient { + fn clone(&self) -> Self { + Self { + listen_handle: self.listen_handle.clone(), + request_sender: self.request_sender.clone(), + watch_receiver: self.watch_receiver.resubscribe(), + } + } +} + +/// Drop the [`ConvexClient`]. When the final reference to the [`ConvexClient`] +/// is dropped, the connection is cleaned up. +impl Drop for ConvexClient { + fn drop(&mut self) { + if let Ok(j_handle) = Arc::try_unwrap( + self.listen_handle + .take() + .expect("INTERNAL BUG: listen handle should never be none"), + ) { + j_handle.abort() + } + } +} + +impl ConvexClient { + /// Constructs a new client for communicating with `deployment_url`. + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn new(deployment_url: &str) -> anyhow::Result { + ConvexClient::new_from_builder(ConvexClientBuilder::new(deployment_url)).await + } + + #[doc(hidden)] + pub async fn new_from_builder(builder: ConvexClientBuilder) -> anyhow::Result { + let client_id = builder + .client_id + .unwrap_or_else(|| format!("rust-{}", VERSION.unwrap_or("unknown"))); + let ws_url = deployment_to_ws_url(builder.deployment_url.as_str().try_into()?)?; + + // Channels for the `listen` background thread + let (response_sender, response_receiver) = mpsc::channel(1); + let (request_sender, request_receiver) = mpsc::unbounded_channel(); + + // Listener for when each transaction completes + let (watch_sender, watch_receiver) = broadcast::channel(1); + + let base_client = BaseConvexClient::new(); + + let protocol = WebSocketManager::open( + ws_url, + response_sender, + builder.on_state_change, + client_id.as_str(), + ) + .await?; + + let listen_handle = tokio::spawn(worker( + response_receiver, + request_receiver, + watch_sender, + base_client, + protocol, + )); + let client = ConvexClient { + listen_handle: Some(Arc::new(listen_handle)), + request_sender, + watch_receiver, + }; + Ok(client) + } + + /// Subscribe to the results of query `name` called with `args`. + /// + /// Returns a [`QuerySubscription`] which implements [`Stream`]< + /// [`FunctionResult`]>. A new value appears on the stream each + /// time the query function produces a new result. + /// + /// The subscription is automatically unsubscribed when it is dropped. + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # use futures::StreamExt; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// let mut sub = client.subscribe("listMessages", maplit::btreemap!{}).await?; + /// while let Some(result) = sub.next().await { + /// println!("{result:?}"); + /// } + /// # Ok(()) + /// # } + pub async fn subscribe( + &mut self, + name: &str, + args: BTreeMap, + ) -> anyhow::Result { + let (tx, rx) = oneshot::channel(); + + let udf_path = name.parse()?; + let request = SubscribeRequest { udf_path, args }; + + self.request_sender.send(ClientRequest::Subscribe( + request, + tx, + self.request_sender.clone(), + ))?; + + let res = rx.await?; + Ok(res) + } + + /// Make a oneshot request to a query `name` with `args`. + /// + /// Returns a [`FunctionResult`] representing the result of the query. + /// + /// This method is syntactic sugar for waiting for a single result on + /// a subscription. + /// It is equivalent to `client.subscribe(name, + /// args).await?.next().unwrap()` + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # use futures::StreamExt; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// let result = client.query("listMessages", maplit::btreemap!{}).await?; + /// println!("{result:?}"); + /// # Ok(()) + /// # } + pub async fn query( + &mut self, + name: &str, + args: BTreeMap, + ) -> anyhow::Result { + Ok(self + .subscribe(name, args) + .await? + .next() + .await + .expect("INTERNAL BUG: Convex Client dropped prematurely.")) + } + + /// Perform a mutation `name` with `args` and return a future + /// containing the return value of the mutation once it completes. + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # use futures::StreamExt; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// let result = client.mutation("sendMessage", maplit::btreemap!{ + /// "body".into() => "Let it be.".into(), + /// "author".into() => "The Beatles".into(), + /// }).await?; + /// println!("{result:?}"); + /// # Ok(()) + /// # } + pub async fn mutation( + &mut self, + name: &str, + args: BTreeMap, + ) -> anyhow::Result { + let (tx, rx) = oneshot::channel(); + + let udf_path: UdfPath = name.parse()?; + let request = MutationRequest { udf_path, args }; + + self.request_sender + .send(ClientRequest::Mutation(request, tx))?; + + let res = rx.await?; + Ok(res.await?) + } + + /// Perform an action `name` with `args` and return a future + /// containing the return value of the action once it completes. + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # use futures::StreamExt; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// let result = client.action("sendGif", maplit::btreemap!{ + /// "body".into() => "Tatooine Sunrise.".into(), + /// "author".into() => "Luke Skywalker".into(), + /// }).await?; + /// println!("{result:?}"); + /// # Ok(()) + /// # } + pub async fn action( + &mut self, + name: &str, + args: BTreeMap, + ) -> anyhow::Result { + let (tx, rx) = oneshot::channel(); + + let udf_path: UdfPath = name.parse()?; + let request = ActionRequest { udf_path, args }; + + self.request_sender + .send(ClientRequest::Action(request, tx))?; + + let res = rx.await?; + Ok(res.await?) + } + + /// Get a consistent view of the results of multiple queries (query set). + /// + /// Returns a [`QuerySetSubscription`] which + /// implements [`Stream`]<[`QueryResults`]>. + /// Each item in the stream contains a consistent view + /// of the results of all the queries in the query set. + /// + /// Queries can be added to the query set via [`ConvexClient::subscribe`]. + /// Queries can be removed from the query set via dropping the + /// [`QuerySubscription`] token returned by [`ConvexClient::subscribe`]. + /// + /// + /// [`QueryResults`] is a copy-on-write mapping from [`SubscriberId`] to + /// its latest result [`Value`]. + /// + /// ```no_run + /// # use convex::ConvexClient; + /// # use futures::StreamExt; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; + /// let mut watch = client.watch_all(); + /// let sub1 = client.subscribe("listMessages", maplit::btreemap!{ + /// "channel".into() => 1.into(), + /// }).await?; + /// let sub2 = client.subscribe("listMessages", maplit::btreemap!{ + /// "channel".into() => 1.into(), + /// }).await?; + /// # Ok(()) + /// # } + pub fn watch_all(&self) -> QuerySetSubscription { + QuerySetSubscription::new(BroadcastStream::new(self.watch_receiver.resubscribe())) + } + + /// Set auth for use when calling Convex functions. + /// + /// Set it with a token that you get from your auth provider via their login + /// flow. If `None` is passed as the token, then auth is unset (logging + /// out). + /// + /// Internally this wraps the static token in a trivial callback and the + /// same token is re-sent on websocket reconnect. + /// + ///
+ /// + /// Prefer [`ConvexClient::set_auth_callback``] - it will allow fetching a + /// fresh token after a websocket reconnect. That's important because + /// the original token might have expired while the socket was + /// disconnected. + /// + ///
+ pub async fn set_auth(&mut self, token: Option) { + let fetcher: Option = token.map(|t| { + Box::new(move |_force_refresh: bool| { + let t = t.clone(); + Box::pin(async move { Ok(AuthenticationToken::User(t)) }) + as Pin> + Send>> + }) as AuthTokenFetcher + }); + self.request_sender + .send(ClientRequest::Authenticate(fetcher)) + .expect("INTERNAL BUG: Worker has gone away"); + } + + /// Set an auth token fetcher callback for use when calling Convex + /// functions. + /// + /// The callback is invoked immediately (with `force_refresh=false`) and + /// again on every websocket reconnect (with `force_refresh=true`), + /// allowing dynamic token refresh. + /// + /// Pass `None` to clear the callback and log out. + pub async fn set_auth_callback(&mut self, fetcher: Option) { + self.request_sender + .send(ClientRequest::Authenticate(fetcher)) + .expect("INTERNAL BUG: Worker has gone away"); + } + + /// Force the client's WebSocket to reconnect and replay its current auth, + /// subscriptions, and in-flight mutations. + pub async fn reconnect_now(&mut self, reason: &str) { + self.request_sender + .send(ClientRequest::Reconnect(reason.to_owned())) + .expect("INTERNAL BUG: Worker has gone away"); + } + + /// Set admin auth for use when calling Convex functions as a deployment + /// admin. Not typically required. + /// + /// You can get a deploy_key from the Convex dashboard's deployment settings + /// page. Deployment admins can act as users as part of their + /// development flow to see how a function would act. + #[doc(hidden)] + pub async fn set_admin_auth( + &mut self, + deploy_key: String, + acting_as: Option, + ) { + let fetcher: AuthTokenFetcher = Box::new(move |_force_refresh: bool| { + let deploy_key = deploy_key.clone(); + let acting_as = acting_as.clone(); + Box::pin(async move { Ok(AuthenticationToken::Admin(deploy_key, acting_as)) }) + }); + self.request_sender + .send(ClientRequest::Authenticate(Some(fetcher))) + .expect("INTERNAL BUG: Worker has gone away"); + } +} + +fn deployment_to_ws_url(mut deployment_url: Url) -> anyhow::Result { + let ws_scheme = match deployment_url.scheme() { + "http" | "ws" => "ws", + "https" | "wss" => "wss", + scheme => anyhow::bail!("Unknown scheme {scheme}. Expected http or https."), + }; + deployment_url + .set_scheme(ws_scheme) + .expect("Scheme not supported"); + deployment_url.set_path("api/sync"); + Ok(deployment_url) +} + +/// A builder for creating a [`ConvexClient`] with custom configuration. +pub struct ConvexClientBuilder { + deployment_url: String, + client_id: Option, + on_state_change: Option>, +} + +impl ConvexClientBuilder { + /// Create a new [`ConvexClientBuilder`] with the given deployment URL. + pub fn new(deployment_url: &str) -> Self { + Self { + deployment_url: deployment_url.to_string(), + client_id: None, + on_state_change: None, + } + } + + /// Set a custom client ID for this client. + pub fn with_client_id(mut self, client_id: &str) -> Self { + self.client_id = Some(client_id.to_string()); + self + } + + /// Set a channel to be notified of changes to the WebSocket connection + /// state. + pub fn with_on_state_change(mut self, on_state_change: mpsc::Sender) -> Self { + self.on_state_change = Some(on_state_change); + self + } + + /// Build the [`ConvexClient`] with the configured options. + /// + /// ```no_run + /// # use convex::ConvexClientBuilder; + /// # #[tokio::main] + /// # async fn main() -> anyhow::Result<()> { + /// let client = ConvexClientBuilder::new("https://cool-music-123.convex.cloud").build().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn build(self) -> anyhow::Result { + ConvexClient::new_from_builder(self).await + } +} + +#[cfg(test)] +pub mod tests { + use std::{ + str::FromStr, + sync::Arc, + time::Duration, + }; + + use convex_sync_types::{ + types::SerializedArgs, + AuthenticationToken, + ClientMessage, + LogLinesMessage, + Query, + QueryId, + QuerySetModification, + SessionId, + StateModification, + StateVersion, + UdfPath, + UserIdentityAttributes, + }; + use futures::StreamExt; + use maplit::btreemap; + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio::sync::{ + broadcast, + mpsc, + }; + + use super::ConvexClient; + use crate::{ + base_client::FunctionResult, + client::{ + deployment_to_ws_url, + worker::worker, + BaseConvexClient, + }, + sync::{ + testing::TestProtocolManager, + ServerMessage, + SyncProtocol, + }, + value::Value, + QuerySubscription, + }; + + impl ConvexClient { + pub async fn with_test_protocol() -> anyhow::Result<(Self, TestProtocolManager)> { + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(); + + // Channels for the `listen` background thread + let (response_sender, response_receiver) = mpsc::channel(1); + let (request_sender, request_receiver) = mpsc::unbounded_channel(); + + // Listener for when each transaction completes + let (watch_sender, watch_receiver) = broadcast::channel(1); + + let test_protocol = TestProtocolManager::open( + "ws://test.com".parse()?, + response_sender, + None, + "rust-0.0.1", + ) + .await?; + let base_client = BaseConvexClient::new(); + + let listen_handle = tokio::spawn(worker( + response_receiver, + request_receiver, + watch_sender, + base_client, + test_protocol.clone(), + )); + + let client = ConvexClient { + listen_handle: Some(Arc::new(listen_handle)), + request_sender, + watch_receiver, + }; + Ok((client, test_protocol)) + } + } + + fn fake_mutation_response(result: FunctionResult) -> (ServerMessage, ServerMessage) { + let (transition_response, new_version) = fake_transition(StateVersion::initial(), vec![]); + let mutation_response = ServerMessage::MutationResponse { + request_id: 0, + result: result.into(), + ts: Some(new_version.ts), + log_lines: LogLinesMessage(vec![]), + }; + (mutation_response, transition_response) + } + + fn fake_action_response(result: FunctionResult) -> ServerMessage { + ServerMessage::ActionResponse { + request_id: 0, + result: result.into(), + log_lines: LogLinesMessage(vec![]), + } + } + + fn fake_transition( + start_version: StateVersion, + modifications: Vec<(QueryId, Value)>, + ) -> (ServerMessage, StateVersion) { + let end_version = StateVersion { + ts: start_version.ts.succ().expect("Succ failed"), + ..start_version + }; + ( + ServerMessage::Transition { + start_version, + end_version, + modifications: modifications + .into_iter() + .map(|(query_id, value)| StateModification::QueryUpdated { + query_id, + value, + journal: None, + log_lines: LogLinesMessage(vec![]), + }) + .collect(), + client_clock_skew: None, + server_ts: None, + }, + end_version, + ) + } + + #[tokio::test] + async fn test_mutation() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + let mut res = + tokio::spawn(async move { client.mutation("incrementCounter", btreemap! {}).await }); + test_protocol.wait_until_n_messages_sent(1).await; + + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Mutation { + request_id: 0, + udf_path: UdfPath::from_str("incrementCounter")?, + args: SerializedArgs::from_args(vec![json!({})])?, + component_path: None, + }] + ); + + let mutation_result = FunctionResult::Value(Value::Null); + let (mut_resp, transition) = fake_mutation_response(mutation_result.clone()); + test_protocol.fake_server_response(mut_resp).await?; + // Should not be ready until transition completes. + tokio::time::timeout(Duration::from_millis(50), &mut res) + .await + .unwrap_err(); + + // Once transition is sent, it is ready. + test_protocol.fake_server_response(transition).await?; + assert_eq!(res.await??, mutation_result); + Ok(()) + } + + #[tokio::test] + async fn test_mutation_error() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + let res = + tokio::spawn(async move { client.mutation("incrementCounter", btreemap! {}).await }); + test_protocol.wait_until_n_messages_sent(1).await; + test_protocol.take_sent().await; + + let mutation_result = FunctionResult::ErrorMessage("JEEPERS".into()); + let (mut_resp, _transition) = fake_mutation_response(mutation_result.clone()); + test_protocol.fake_server_response(mut_resp).await?; + // Errors should be ready immediately (no transition needed) + assert_eq!(res.await??, mutation_result); + + Ok(()) + } + + #[tokio::test] + async fn test_action() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + let action_result = FunctionResult::Value(Value::Null); + let server_message = fake_action_response(action_result.clone()); + + let res = tokio::spawn(async move { client.action("runAction:hello", btreemap! {}).await }); + test_protocol.wait_until_n_messages_sent(1).await; + + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Action { + request_id: 0, + udf_path: UdfPath::from_str("runAction:hello")?, + args: SerializedArgs::from_args(vec![json!({})])?, + component_path: None, + }] + ); + + test_protocol.fake_server_response(server_message).await?; + assert_eq!(res.await??, action_result); + Ok(()) + } + + #[tokio::test] + async fn test_auth() -> anyhow::Result<()> { + let (mut client, test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + // Set token + client.set_auth(Some("myauthtoken".into())).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 0, + token: AuthenticationToken::User("myauthtoken".into()), + }] + ); + + // Unset token + client.set_auth(None).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 1, + token: AuthenticationToken::None, + }] + ); + + // Set admin auth + client.set_admin_auth("myadminauth".into(), None).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 2, + token: AuthenticationToken::Admin("myadminauth".into(), None), + }] + ); + + // Set admin auth acting as user + let acting_as = UserIdentityAttributes { + name: Some("Barbara Liskov".into()), + ..Default::default() + }; + client + .set_admin_auth("myadminauth".into(), Some(acting_as.clone())) + .await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 3, + token: AuthenticationToken::Admin("myadminauth".into(), Some(acting_as)), + }] + ); + Ok(()) + } + + #[tokio::test] + async fn test_auth_callback() -> anyhow::Result<()> { + let (mut client, test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + // Set auth via callback + let fetcher: crate::client::AuthTokenFetcher = Box::new(|_force_refresh| { + Box::pin(async { Ok(AuthenticationToken::User("callback_token".into())) }) + }); + client.set_auth_callback(Some(fetcher)).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 0, + token: AuthenticationToken::User("callback_token".into()), + }] + ); + + // Clear auth via callback + client.set_auth_callback(None).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 1, + token: AuthenticationToken::None, + }] + ); + Ok(()) + } + + #[tokio::test] + async fn test_auth_callback_returning_none() -> anyhow::Result<()> { + let (mut client, test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + // Callback that returns None (no token) + let fetcher: crate::client::AuthTokenFetcher = + Box::new(|_force_refresh| Box::pin(async { Ok(AuthenticationToken::None) })); + client.set_auth_callback(Some(fetcher)).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 0, + token: AuthenticationToken::None, + }] + ); + Ok(()) + } + + #[tokio::test] + async fn test_set_auth_uses_callback_path() -> anyhow::Result<()> { + let (mut client, test_protocol) = ConvexClient::with_test_protocol().await?; + test_protocol.take_sent().await; + + // set_auth with a token should send the same Authenticate message as before + client.set_auth(Some("static_token".into())).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 0, + token: AuthenticationToken::User("static_token".into()), + }] + ); + + // set_auth(None) clears auth + client.set_auth(None).await; + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::Authenticate { + base_version: 1, + token: AuthenticationToken::None, + }] + ); + Ok(()) + } + + #[tokio::test] + async fn test_client_single_subscription() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + + let mut subscription1 = client.subscribe("getValue1", btreemap! {}).await?; + let query_id = subscription1.query_id(); + assert_eq!( + test_protocol.take_sent().await, + vec![ + ClientMessage::Connect { + session_id: SessionId::nil(), + connection_count: 0, + last_close_reason: "InitialConnect".to_string(), + max_observed_timestamp: None, + client_ts: None, + }, + ClientMessage::ModifyQuerySet { + base_version: 0, + new_version: 1, + modifications: vec![QuerySetModification::Add(Query { + query_id, + udf_path: "getValue1".parse()?, + args: SerializedArgs::from_args(vec![json!({})])?, + journal: None, + component_path: None, + })] + }, + ] + ); + + test_protocol + .fake_server_response( + fake_transition( + StateVersion::initial(), + vec![(subscription1.query_id(), 10.into())], + ) + .0, + ) + .await?; + assert_eq!( + subscription1.next().await, + Some(FunctionResult::Value(10.into())) + ); + assert_eq!( + client.query("getValue1", btreemap! {}).await?, + FunctionResult::Value(10.into()) + ); + + drop(subscription1); + test_protocol.wait_until_n_messages_sent(1).await; + assert_eq!( + test_protocol.take_sent().await, + vec![ClientMessage::ModifyQuerySet { + base_version: 1, + new_version: 2, + modifications: vec![QuerySetModification::Remove { query_id }], + }] + ); + + Ok(()) + } + + #[tokio::test] + async fn test_client_subscribe_unsubscribe_subscribe() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + let subscription1b: QuerySubscription; + { + // This subscription goes out of scope and unsubscribes at the end of this + // block. The internal num_subscribers value gets decremented. + let _ignored = client.subscribe("getValue1", btreemap! {}).await?; + subscription1b = client.subscribe("getValue1", btreemap! {}).await?; + } + // In the buggy scenario, this subscription gets an ID via num_subscribers ID + // that matches subscription1b. That triggers a panic. + let subscription1c = client.subscribe("getValue1", btreemap! {}).await?; + test_protocol.take_sent().await; + let mut watch = client.watch_all(); + + test_protocol + .fake_server_response( + fake_transition(StateVersion::initial(), vec![(QueryId::new(0), 10.into())]).0, + ) + .await?; + + let results = watch.next().await.expect("Watch should have results"); + assert_eq!( + results.get(&subscription1b), + Some(&FunctionResult::Value(10.into())) + ); + assert_eq!( + results.get(&subscription1c), + Some(&FunctionResult::Value(10.into())) + ); + Ok(()) + } + + #[tokio::test] + async fn test_client_consistent_view_watch() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + let subscription1 = client.subscribe("getValue1", btreemap! {}).await?; + let subscription2a = client.subscribe("getValue2", btreemap! {}).await?; + let subscription2b = client.subscribe("getValue2", btreemap! {}).await?; + let subscription3 = client.subscribe("getValue3", btreemap! {}).await?; + test_protocol.take_sent().await; + let mut watch = client.watch_all(); + + test_protocol + .fake_server_response( + fake_transition( + StateVersion::initial(), + vec![(QueryId::new(0), 10.into()), (QueryId::new(1), 20.into())], + ) + .0, + ) + .await?; + + let results = watch.next().await.expect("Watch should have results"); + assert_eq!( + results.get(&subscription1), + Some(&FunctionResult::Value(10.into())) + ); + assert_eq!( + results.get(&subscription2a), + Some(&FunctionResult::Value(20.into())) + ); + assert_eq!( + results.get(&subscription2b), + Some(&FunctionResult::Value(20.into())) + ); + assert_eq!(results.get(&subscription3), None); + assert_eq!( + results.iter().collect::>(), + vec![ + (subscription1.id(), Some(&FunctionResult::Value(10.into()))), + (subscription2a.id(), Some(&FunctionResult::Value(20.into()))), + (subscription2b.id(), Some(&FunctionResult::Value(20.into()))), + (subscription3.id(), None,), + ] + ); + + // Ideally a new watch should immediately give you results, but we don't have + // that yet. Need to replace tokio::broadcast with something that buffers 1 + // item. + //let mut watch2 = client.watch(); + //let results = watch.next().await.expect("Watch should have results"); + //assert_eq!(results.len(), 3); + + Ok(()) + } + + #[tokio::test] + async fn test_drop_client() -> anyhow::Result<()> { + let (mut client, _test_protocol) = ConvexClient::with_test_protocol().await?; + let mut subscription1 = client.subscribe("getValue1", btreemap! {}).await?; + drop(client); + tokio::task::yield_now().await; + assert!(subscription1.next().await.is_none()); + drop(subscription1); + Ok(()) + } + + #[tokio::test] + async fn test_client_separate_queries() -> anyhow::Result<()> { + let (mut client, test_protocol) = ConvexClient::with_test_protocol().await?; + + // All three of these should be considered separate + let subscription1 = client.subscribe("getValue1", btreemap! {}).await?; + let subscription2 = client.subscribe("getValue2", btreemap! {}).await?; + let subscription3 = client + .subscribe("getValue2", btreemap! {"hello".into() => "world".into()}) + .await?; + assert_ne!(subscription1.query_id(), subscription2.query_id()); + assert_ne!(subscription2.query_id(), subscription3.query_id()); + + assert_eq!( + test_protocol.take_sent().await, + vec![ + ClientMessage::Connect { + session_id: SessionId::nil(), + connection_count: 0, + last_close_reason: "InitialConnect".to_string(), + max_observed_timestamp: None, + client_ts: None, + }, + ClientMessage::ModifyQuerySet { + base_version: 0, + new_version: 1, + modifications: vec![QuerySetModification::Add(Query { + query_id: subscription1.query_id(), + udf_path: "getValue1".parse()?, + args: SerializedArgs::from_args(vec![json!({})])?, + journal: None, + component_path: None, + })] + }, + ClientMessage::ModifyQuerySet { + base_version: 1, + new_version: 2, + modifications: vec![QuerySetModification::Add(Query { + query_id: subscription2.query_id(), + udf_path: "getValue2".parse()?, + args: SerializedArgs::from_args(vec![json!({})])?, + journal: None, + component_path: None, + })] + }, + ClientMessage::ModifyQuerySet { + base_version: 2, + new_version: 3, + modifications: vec![QuerySetModification::Add(Query { + query_id: subscription3.query_id(), + udf_path: "getValue2".parse()?, + args: SerializedArgs::from_args(vec![json!({"hello": "world"})])?, + journal: None, + component_path: None, + })] + }, + ] + ); + + Ok(()) + } + + #[tokio::test] + async fn test_client_two_identical_queries() -> anyhow::Result<()> { + let (mut client, mut test_protocol) = ConvexClient::with_test_protocol().await?; + + // These two should be considered the same query. + let mut subscription1 = client.subscribe("getValue", btreemap! {}).await?; + let mut subscription2 = client.subscribe("getValue", btreemap! {}).await?; + + assert_ne!(subscription1.subscriber_id, subscription2.subscriber_id); + assert_eq!(subscription1.query_id(), subscription2.query_id()); + let query_id = subscription1.query_id(); + + assert_eq!( + test_protocol.take_sent().await, + vec![ + ClientMessage::Connect { + session_id: SessionId::nil(), + connection_count: 0, + last_close_reason: "InitialConnect".to_string(), + max_observed_timestamp: None, + client_ts: None, + }, + ClientMessage::ModifyQuerySet { + base_version: 0, + new_version: 1, + modifications: vec![QuerySetModification::Add(Query { + query_id, + udf_path: "getValue".parse()?, + args: SerializedArgs::from_args(vec![json!({})])?, + journal: None, + component_path: None, + })] + }, + ] + ); + + let mut version = StateVersion::initial(); + for i in 1..5 { + let (transition, new_version) = fake_transition(version, vec![(query_id, i.into())]); + test_protocol.fake_server_response(transition).await?; + version = new_version; + + assert_eq!( + subscription1.next().await, + Some(FunctionResult::Value(i.into())) + ); + assert_eq!( + subscription2.next().await, + Some(FunctionResult::Value(i.into())) + ); + } + + // A new subscription should auto-initialize with the value if available + let mut subscription3 = client.subscribe("getValue", btreemap! {}).await?; + assert_eq!( + subscription3.next().await, + Some(FunctionResult::Value(4.into())), + ); + + // Dropping sub1 and sub2 should still maintain subscription + drop(subscription1); + drop(subscription2); + let (transition, _new_version) = fake_transition(version, vec![(query_id, 5.into())]); + test_protocol.fake_server_response(transition).await?; + assert_eq!( + subscription3.next().await, + Some(FunctionResult::Value(5.into())), + ); + + Ok(()) + } + + #[test] + fn test_deployment_url() -> anyhow::Result<()> { + assert_eq!( + deployment_to_ws_url("http://flying-shark-123.convex.cloud".parse()?)?.to_string(), + "ws://flying-shark-123.convex.cloud/api/sync", + ); + assert_eq!( + deployment_to_ws_url("https://flying-shark-123.convex.cloud".parse()?)?.to_string(), + "wss://flying-shark-123.convex.cloud/api/sync", + ); + assert_eq!( + deployment_to_ws_url("ws://flying-shark-123.convex.cloud".parse()?)?.to_string(), + "ws://flying-shark-123.convex.cloud/api/sync", + ); + assert_eq!( + deployment_to_ws_url("wss://flying-shark-123.convex.cloud".parse()?)?.to_string(), + "wss://flying-shark-123.convex.cloud/api/sync", + ); + assert_eq!( + deployment_to_ws_url("ftp://flying-shark-123.convex.cloud".parse()?) + .unwrap_err() + .to_string(), + "Unknown scheme ftp. Expected http or https.", + ); + Ok(()) + } +} diff --git a/third_party/convex_rs/src/client/subscription.rs b/third_party/convex_rs/src/client/subscription.rs new file mode 100644 index 00000000..0f3b4786 --- /dev/null +++ b/third_party/convex_rs/src/client/subscription.rs @@ -0,0 +1,149 @@ +use std::{ + ops::Deref, + pin::Pin, +}; + +use futures::{ + task, + Stream, + StreamExt, +}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::{ + errors::BroadcastStreamRecvError, + BroadcastStream, +}; + +use crate::{ + base_client::{ + FunctionResult, + QueryResults, + SubscriberId, + }, + client::worker::{ + ClientRequest, + UnsubscribeRequest, + }, +}; +#[cfg(doc)] +use crate::{ + ConvexClient, + Value, +}; + +/// This structure represents a single subscription to a query with args. +/// For convenience, [`QuerySubscription`] also implements +/// [`Stream`]<[`FunctionResult`]>, giving a stream of results to the query. +/// +/// It is returned by [`ConvexClient::subscribe`]. The subscription lives +/// in the active query set for as long as this token stays in scope. +/// +/// For a consistent [`QueryResults`] of all your queries, use +/// [`ConvexClient::watch_all()`] instead. +pub struct QuerySubscription { + pub(super) subscriber_id: SubscriberId, + pub(super) request_sender: mpsc::UnboundedSender, + pub(super) watch: BroadcastStream, + pub(super) initial: Option, +} +impl QuerySubscription { + /// Returns an identifier for this subscription based on its query and args. + /// This identifier can be used to find the result within a + /// [`QuerySetSubscription`] as returned by [`ConvexClient::watch_all()`] + pub fn id(&self) -> &SubscriberId { + &self.subscriber_id + } +} +impl std::fmt::Debug for QuerySubscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QuerySubscription") + .field("subscriber_id", &self.subscriber_id) + .finish() + } +} +impl Deref for QuerySubscription { + type Target = SubscriberId; + + fn deref(&self) -> &SubscriberId { + &self.subscriber_id + } +} +impl Drop for QuerySubscription { + fn drop(&mut self) { + let _ = self + .request_sender + .send(ClientRequest::Unsubscribe(UnsubscribeRequest { + subscriber_id: self.subscriber_id, + })); + } +} +impl Stream for QuerySubscription { + type Item = FunctionResult; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut task::Context<'_>, + ) -> task::Poll> { + if let Some(initial) = self.initial.take() { + return task::Poll::Ready(Some(initial)); + } + loop { + return match self.watch.poll_next_unpin(cx) { + // Ok to be lagged (skip intermediate values) - since Convex + // only guarantees a newer value than the previous value. + task::Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_amt)))) => continue, + task::Poll::Ready(Some(Ok(map))) => { + let Some(value) = map.get(self.id()) else { + // No result yet in the query result set. Keep polling. + continue; + }; + task::Poll::Ready(Some(value.clone())) + }, + task::Poll::Ready(None) => task::Poll::Ready(None), + task::Poll::Pending => task::Poll::Pending, + }; + } + } +} + +/// A subscription to a consistent view of multiple queries. +/// +/// [`QuerySetSubscription`] +/// implements [`Stream`]<[`QueryResults`]>. +/// Each item in the stream contains a consistent view +/// of the results of all the queries in the query set. +/// +/// Queries can be added to the query set via [`ConvexClient::subscribe`]. +/// Queries can be removed from the query set via dropping the +/// [`QuerySubscription`] token returned by [`ConvexClient::subscribe`]. +/// +/// +/// [`QueryResults`] is a copy-on-write mapping from [`SubscriberId`] to +/// its latest result [`Value`]. +pub struct QuerySetSubscription { + watch: BroadcastStream, +} +impl QuerySetSubscription { + pub(super) fn new(watch: BroadcastStream) -> Self { + Self { watch } + } +} +impl Stream for QuerySetSubscription { + type Item = QueryResults; + + fn poll_next( + mut self: Pin<&mut Self>, + cx: &mut task::Context<'_>, + ) -> task::Poll> { + loop { + return match self.watch.poll_next_unpin(cx) { + // Ok to be lagged (skip intermediate values) - since Convex + // only guarantees a newer value than the previous value. + task::Poll::Ready(Some(Err(BroadcastStreamRecvError::Lagged(_amt)))) => continue, + task::Poll::Ready(Some(Ok(map))) => task::Poll::Ready(Some(map)), + task::Poll::Ready(None) => task::Poll::Ready(None), + task::Poll::Pending => task::Poll::Pending, + }; + } + } +} diff --git a/third_party/convex_rs/src/client/worker.rs b/third_party/convex_rs/src/client/worker.rs new file mode 100644 index 00000000..bd001304 --- /dev/null +++ b/third_party/convex_rs/src/client/worker.rs @@ -0,0 +1,367 @@ +use std::{ + collections::BTreeMap, + convert::Infallible, + time::Duration, +}; + +use convex_sync_types::{ + backoff::Backoff, + UdfPath, +}; +use tokio::sync::{ + broadcast, + mpsc, + oneshot, +}; +use tokio_stream::wrappers::BroadcastStream; + +use crate::{ + base_client::{ + AuthTokenFetcher, + BaseConvexClient, + SubscriberId, + }, + client::{ + QueryResults, + QuerySubscription, + }, + sync::{ + ProtocolResponse, + ReconnectProtocolReason, + ReconnectRequest, + SyncProtocol, + }, + value::Value, + FunctionResult, +}; + +const INITIAL_BACKOFF: Duration = Duration::from_millis(100); +const MAX_BACKOFF: Duration = Duration::from_secs(15); +const AUTH_RETRY_DELAY: Duration = Duration::from_millis(250); + +fn is_auth_rejection(reason: &str) -> bool { + reason.starts_with("AuthError:") +} + +#[derive(Default)] +struct AuthRecovery { + active: bool, + fresh_token_in_flight: bool, +} + +pub enum ClientRequest { + Mutation( + MutationRequest, + oneshot::Sender>, + ), + Action( + ActionRequest, + oneshot::Sender>, + ), + Subscribe( + SubscribeRequest, + oneshot::Sender, + mpsc::UnboundedSender, + ), + Unsubscribe(UnsubscribeRequest), + Authenticate(Option), + Reconnect(String), +} + +pub struct MutationRequest { + pub udf_path: UdfPath, + pub args: BTreeMap, +} + +pub struct ActionRequest { + pub udf_path: UdfPath, + pub args: BTreeMap, +} + +pub struct SubscribeRequest { + pub udf_path: UdfPath, + pub args: BTreeMap, +} + +#[derive(Debug)] +pub struct UnsubscribeRequest { + pub subscriber_id: SubscriberId, +} + +pub async fn worker( + mut protocol_response_receiver: mpsc::Receiver, + mut client_request_receiver: mpsc::UnboundedReceiver, + mut watch_sender: broadcast::Sender, + mut base_client: BaseConvexClient, + mut protocol_manager: T, +) -> Infallible { + let mut backoff = Backoff::new(INITIAL_BACKOFF, MAX_BACKOFF); + let mut auth_recovery = AuthRecovery::default(); + loop { + let e = loop { + match _worker_once( + &mut protocol_response_receiver, + &mut client_request_receiver, + &mut watch_sender, + &mut base_client, + &mut protocol_manager, + &mut auth_recovery, + ) + .await + { + Ok(()) => backoff.reset(), + Err(e) => break e, + } + }; + + if is_auth_rejection(&e) { + auth_recovery.active = true; + auth_recovery.fresh_token_in_flight = false; + } + + // A server auth rejection and the protocol errors cascading from that + // rejected socket are not network outages. The reconnect below + // invokes the stored token callback with `force_refresh=true`, so retry + // it at a small fixed cadence while the auth provider obtains a fresh + // token. Applying the generic exponential network backoff here can + // otherwise delay a ready token for up to MAX_BACKOFF. + let delay = if auth_recovery.active { + backoff.reset(); + AUTH_RETRY_DELAY + } else { + backoff.fail(&mut rand::rng()) + }; + tracing::error!( + "Convex Client Worker failed: {e:?}. Backing off for {delay:?} and retrying." + ); + tokio::time::sleep(delay).await; + + // Everything currently buffered came from the connection we are about + // to replace. In particular, an auth error can be followed by the old + // socket closing and queuing ProtocolFailure while this worker is in + // its retry delay. Replaying that stale failure after the new socket is + // up starts a second, unrelated backoff and can leave fresh auth behind + // it. Query and mutation state is rebuilt below, so discard the old + // connection's buffered responses before requesting the replacement. + while protocol_response_receiver.try_recv().is_ok() {} + + // Tell the sync protocol to reconnect followed by an immediate resend of + // ongoing queries/mutations. It's important these happen together to + // ensure mutation ordering. If an auth token fetcher is stored, + // resend_ongoing_queries_mutations will refresh the token first. + protocol_manager + .reconnect(ReconnectRequest { + reason: e, + max_observed_timestamp: base_client.max_observed_timestamp(), + auth_retry: auth_recovery.active, + }) + .await; + let auth_token_changed = base_client.resend_ongoing_queries_mutations().await; + if auth_recovery.active && auth_token_changed { + auth_recovery.fresh_token_in_flight = true; + } + // We'll flush messages from base_client inside the next call to + // `_worker_once`. + } +} + +#[cfg(test)] +mod tests { + use super::is_auth_rejection; + + #[test] + fn only_server_auth_errors_use_the_auth_retry_path() { + assert!(is_auth_rejection( + "AuthError: token expired for identity version 1" + )); + assert!(!is_auth_rejection("ProtocolFailure")); + assert!(!is_auth_rejection("convex_flutter:manual")); + } +} + +async fn _worker_once( + protocol_response_receiver: &mut mpsc::Receiver, + client_request_receiver: &mut mpsc::UnboundedReceiver, + watch_sender: &mut broadcast::Sender, + base_client: &mut BaseConvexClient, + protocol_manager: &mut T, + auth_recovery: &mut AuthRecovery, +) -> Result<(), ReconnectProtocolReason> { + // If there are any outgoing messages to flush (e.g. from an outer reconnect), + // do so first. + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + + tokio::select! { + Some(protocol_response) = protocol_response_receiver.recv() => { + handle_protocol_response( + base_client, + watch_sender, + protocol_response, + auth_recovery, + )?; + } + Some(client_request) = client_request_receiver.recv() => { + match client_request { + ClientRequest::Subscribe(query, tx, request_sender) => { + let watch = watch_sender.subscribe(); + let SubscribeRequest { + udf_path, + args, + } = query; + let subscriber_id = base_client.subscribe(udf_path, args); + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + + let watch = BroadcastStream::new(watch); + let subscription = QuerySubscription { + subscriber_id, + request_sender, + watch, + initial: base_client.latest_results().get(&subscriber_id).cloned(), + }; + let _ = tx.send(subscription); + }, + ClientRequest::Mutation(mutation, tx) => { + let MutationRequest { + udf_path, + args, + } = mutation; + let result_receiver = base_client + .mutation(udf_path, args); + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + let _ = tx.send(result_receiver); + }, + ClientRequest::Action(action, tx) => { + let ActionRequest { + udf_path, + args, + } = action; + let result_receiver = base_client + .action(udf_path, args); + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + let _ = tx.send(result_receiver); + }, + ClientRequest::Unsubscribe(unsubscribe) => { + let UnsubscribeRequest {subscriber_id} = unsubscribe; + base_client.unsubscribe(subscriber_id); + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + }, + ClientRequest::Authenticate(fetcher) => { + let token_changed = base_client.set_auth_fetcher(fetcher).await; + if auth_recovery.active && token_changed { + auth_recovery.fresh_token_in_flight = true; + } + communicate( + base_client, + protocol_response_receiver, + watch_sender, + protocol_manager, + auth_recovery, + ) + .await?; + }, + ClientRequest::Reconnect(reason) => return Err(reason), + } + }, + // TODO: this else branch will lead to an infinite loop if both channels + // are closed + else => (), + } + Ok(()) +} + +/// Flush all messages to the protocol while processing server mesages. +async fn communicate( + base_client: &mut BaseConvexClient, + protocol_response_receiver: &mut mpsc::Receiver, + watch_sender: &mut broadcast::Sender, + protocol: &mut P, + auth_recovery: &mut AuthRecovery, +) -> Result<(), ReconnectProtocolReason> { + while let Some(modification) = base_client.pop_next_message() { + let mut send_future = protocol.send(modification); + loop { + tokio::select! { + _ = &mut send_future => break, + // Keep processing protocol responses while waiting so that we + // don't deadlock with the websocket worker. + Some(protocol_response) = protocol_response_receiver.recv() => { + handle_protocol_response( + base_client, + watch_sender, + protocol_response, + auth_recovery, + )?; + } + } + } + } + Ok(()) +} + +fn handle_protocol_response( + base_client: &mut BaseConvexClient, + watch_sender: &mut broadcast::Sender, + protocol_response: ProtocolResponse, + auth_recovery: &mut AuthRecovery, +) -> Result<(), ReconnectProtocolReason> { + match protocol_response { + ProtocolResponse::ServerMessage(msg) => { + let proves_protocol_auth = matches!( + &msg, + crate::sync::ServerMessage::Transition { .. } + | crate::sync::ServerMessage::MutationResponse { .. } + | crate::sync::ServerMessage::ActionResponse { .. } + ); + if let Some(subscriber_id_to_latest_value) = base_client.receive_message(msg)? { + // Notify watchers of the new consistent query results at new timestamp + let _ = watch_sender.send(subscriber_id_to_latest_value); + } + if proves_protocol_auth && auth_recovery.fresh_token_in_flight { + // Only a response after the refresh callback produced a + // different token proves recovery. Transitions buffered while + // the rejected token was still current cannot clear this state. + auth_recovery.active = false; + auth_recovery.fresh_token_in_flight = false; + } + }, + ProtocolResponse::Failure => { + return Err("ProtocolFailure".into()); + }, + } + Ok(()) +} diff --git a/third_party/convex_rs/src/lib.rs b/third_party/convex_rs/src/lib.rs new file mode 100644 index 00000000..f87b14bc --- /dev/null +++ b/third_party/convex_rs/src/lib.rs @@ -0,0 +1,75 @@ +//! # Convex Client +//! The official Rust client for [Convex](https://convex.dev). +//! +//! Convex is the backend application platform with everything you need to build +//! your product. Convex clients can subscribe to queries and perform mutations +//! and actions. Check out the [Convex Documentation](https://docs.convex.dev) for more information. +//! +//! # Usage +//! ## Native Rust development +//! To use Convex to create native Rust applications with [`tokio`], you can use +//! the [`ConvexClient`] struct directly. All you need is your deployment URL +//! from your existing project, and you can subscribe to queries and call +//! mutations. To make a new project, check out our [getting started guide](https://docs.convex.dev/get-started). +//! +//! ```no_run +//! use convex::ConvexClient; +//! use futures::StreamExt; +//! +//! #[tokio::main] +//! async fn main() -> anyhow::Result<()> { +//! let mut client = ConvexClient::new("https://cool-music-123.convex.cloud").await?; +//! client.mutation("sendMessage", maplit::btreemap!{ +//! "body".into() => "Let it be.".into(), +//! "author".into() => "The Beatles".into(), +//! }).await?; +//! let mut sub = client.subscribe("listMessages", maplit::btreemap!{}).await?; +//! while let Some(result) = sub.next().await { +//! println!("{result:?}"); +//! } +//! Ok(()) +//! } +//! ``` +//! +//! ## Extending client for other programming languages or frameworks. +//! To extend Convex into non-[`tokio`] frameworks, +//! you can use the [`base_client::BaseConvexClient`] to build something similar +//! to a [`ConvexClient`]. +//! +//! Detailed examples of both use cases are documented for each struct. + +#![cfg_attr(not(test), warn(missing_docs))] +#![warn(rustdoc::missing_crate_level_docs)] + +mod value; +#[cfg(any(test, feature = "testing"))] +pub use value::export::roundtrip::ExportContext; +pub use value::{ + ConvexError, + Value, +}; + +mod client; +pub use client::{ + subscription::{ + QuerySetSubscription, + QuerySubscription, + }, + ConvexClient, + ConvexClientBuilder, +}; +#[cfg(any(test, feature = "testing"))] +pub use sync::testing; +pub use sync::WebSocketState; + +pub mod base_client; +#[doc(inline)] +pub use base_client::{ + AuthTokenFetcher, + FunctionResult, + QueryResults, + SubscriberId, +}; +pub use convex_sync_types::AuthenticationToken; + +mod sync; diff --git a/third_party/convex_rs/src/sync/mod.rs b/third_party/convex_rs/src/sync/mod.rs new file mode 100644 index 00000000..a3dc1b2a --- /dev/null +++ b/third_party/convex_rs/src/sync/mod.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; +use convex_sync_types::{ + ClientMessage, + Timestamp, +}; +use tokio::sync::mpsc; +use url::Url; + +use crate::value::Value; + +#[cfg(any(test, feature = "testing"))] +pub mod testing; +pub mod web_socket_manager; + +/// Upon a protocol failure, an explanation of the failure to pass in on +/// reconnect +#[derive(Debug)] +pub struct ReconnectRequest { + pub reason: ReconnectProtocolReason, + pub max_observed_timestamp: Option, + /// The old socket failed while recovering a server auth rejection. The + /// client worker already paces these retries, so the WebSocket layer must + /// not add its independent network backoff. + pub auth_retry: bool, +} + +pub type ReconnectProtocolReason = String; + +pub type ServerMessage = convex_sync_types::ServerMessage; + +#[derive(Debug)] +pub enum ProtocolResponse { + ServerMessage(ServerMessage), + Failure, +} + +#[derive(Debug)] +/// The state of the Convex WebSocket connection +pub enum WebSocketState { + /// The WebSocket is open and connected + Connected, + /// The WebSocket is closed and connecting/reconnecting + Connecting, +} + +#[async_trait] +pub trait SyncProtocol: Send + Sized { + async fn open( + ws_url: Url, + on_response: mpsc::Sender, + on_state_change: Option>, + client_id: &str, + ) -> anyhow::Result; + async fn send(&mut self, message: ClientMessage) -> anyhow::Result<()>; + async fn reconnect(&mut self, request: ReconnectRequest); +} diff --git a/third_party/convex_rs/src/sync/testing.rs b/third_party/convex_rs/src/sync/testing.rs new file mode 100644 index 00000000..1e044e0e --- /dev/null +++ b/third_party/convex_rs/src/sync/testing.rs @@ -0,0 +1,106 @@ +#![allow(missing_docs)] +use std::{ + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use convex_sync_types::{ + ClientMessage, + SessionId, +}; +use parking_lot::Mutex; +use tokio::sync::mpsc; +use url::Url; +use uuid::Uuid; + +use super::{ + ReconnectRequest, + WebSocketState, +}; +use crate::sync::{ + ProtocolResponse, + ServerMessage, + SyncProtocol, +}; + +#[derive(Debug)] +struct TestProtocolInner { + closed: bool, + sent_messages: Vec, +} +/// TestProtocolManager +#[derive(Debug, Clone)] +pub struct TestProtocolManager { + inner: Arc>, + response_sender: mpsc::Sender, +} + +impl TestProtocolManager { + pub async fn fake_server_response(&mut self, message: ServerMessage) -> anyhow::Result<()> { + self.response_sender + .send(ProtocolResponse::ServerMessage(message)) + .await?; + Ok(()) + } + + pub async fn wait_until_n_messages_sent(&self, n: usize) { + tokio::time::timeout(Duration::from_secs(2), async { + while self.inner.lock().sent_messages.len() < n { + tokio::task::yield_now().await; + } + }) + .await + .expect("Test timed out waiting for messages to be sent"); + } + + pub async fn take_sent(&self) -> Vec { + std::mem::take(&mut self.inner.lock().sent_messages) + } +} + +#[async_trait] +impl SyncProtocol for TestProtocolManager { + async fn open( + _ws_url: Url, + response_sender: mpsc::Sender, + _on_state_change: Option>, + _client_id: &str, + ) -> anyhow::Result { + let mut test_protocol = TestProtocolManager { + inner: Arc::new(Mutex::new(TestProtocolInner { + closed: false, + sent_messages: vec![], + })), + response_sender, + }; + + let session_id = Uuid::nil(); + let connection_count = 0; + + test_protocol + .send(ClientMessage::Connect { + session_id: SessionId::new(session_id), + connection_count, + last_close_reason: "InitialConnect".to_string(), + max_observed_timestamp: None, + client_ts: None, + }) + .await?; + + Ok(test_protocol) + } + + async fn send(&mut self, message: ClientMessage) -> anyhow::Result<()> { + if self.inner.lock().closed { + anyhow::ensure!(!self.inner.lock().closed, "Websocket is closed"); + } + self.inner.lock().sent_messages.push(message); + + Ok(()) + } + + async fn reconnect(&mut self, request: ReconnectRequest) { + panic!("Test reconnected {request:?}"); + } +} diff --git a/third_party/convex_rs/src/sync/web_socket_manager.rs b/third_party/convex_rs/src/sync/web_socket_manager.rs new file mode 100644 index 00000000..8ac5b8c6 --- /dev/null +++ b/third_party/convex_rs/src/sync/web_socket_manager.rs @@ -0,0 +1,375 @@ +use std::{ + convert::Infallible, + time::Duration, +}; + +use anyhow::Context; +use async_trait::async_trait; +use convex_sync_types::{ + backoff::Backoff, + headers::{ + DEPRECATION_MSG_HEADER_NAME, + DEPRECATION_STATE_HEADER_NAME, + }, + ClientMessage, + SessionId, + Timestamp, +}; +use futures::{ + select_biased, + stream::Fuse, + FutureExt, + SinkExt, + StreamExt, +}; +use tokio::{ + net::TcpStream, + sync::{ + mpsc, + oneshot, + }, + task::JoinHandle, + time::{ + Instant, + Interval, + }, +}; +use tokio_stream::wrappers::UnboundedReceiverStream; +use tokio_tungstenite::{ + connect_async, + tungstenite::{ + self, + client::IntoClientRequest, + http::HeaderMap, + protocol::Message, + }, + MaybeTlsStream, + WebSocketStream, +}; +use url::Url; +use uuid::Uuid; + +use super::WebSocketState; +use crate::sync::{ + ProtocolResponse, + ReconnectRequest, + ServerMessage, + SyncProtocol, +}; + +const INITIAL_BACKOFF: Duration = Duration::from_millis(100); +const MAX_BACKOFF: Duration = Duration::from_secs(15); +type WsStream = WebSocketStream>; + +#[derive(Debug)] +enum WebSocketRequest { + SendMessage(ClientMessage, oneshot::Sender<()>), + Reconnect(ReconnectRequest), +} + +struct WebSocketInternal { + ws_stream: WsStream, + last_server_response: Instant, +} +struct WebSocketWorker { + ws_url: Url, + on_response: mpsc::Sender, + on_state_change: Option>, + internal_receiver: Fuse>, + ping_ticker: Interval, + session_id: SessionId, + connection_count: u32, + backoff: Backoff, +} + +pub struct WebSocketManager { + internal_sender: mpsc::UnboundedSender, + worker_handle: JoinHandle, +} +impl Drop for WebSocketManager { + fn drop(&mut self) { + self.worker_handle.abort() + } +} + +#[async_trait] +impl SyncProtocol for WebSocketManager { + async fn open( + ws_url: Url, + on_response: mpsc::Sender, + on_state_change: Option>, + client_id: &str, + ) -> anyhow::Result { + let (internal_sender, internal_receiver) = mpsc::unbounded_channel(); + let worker_handle = tokio::spawn(WebSocketWorker::run( + ws_url, + on_response, + on_state_change, + internal_receiver, + client_id.to_string(), + )); + + Ok(WebSocketManager { + internal_sender, + worker_handle, + }) + } + + async fn send(&mut self, message: ClientMessage) -> anyhow::Result<()> { + let (tx, rx) = oneshot::channel(); + self.internal_sender + .send(WebSocketRequest::SendMessage(message, tx))?; + rx.await?; + Ok(()) + } + + async fn reconnect(&mut self, request: ReconnectRequest) { + let _ = self + .internal_sender + .send(WebSocketRequest::Reconnect(request)); + } +} + +impl WebSocketWorker { + /// How often heartbeat pings are sent. + const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); + /// How long before lack of server response causes a timeout. + const SERVER_INACTIVITY_THRESHOLD: Duration = Duration::from_secs(30); + + async fn run( + ws_url: Url, + on_response: mpsc::Sender, + on_state_change: Option>, + internal_receiver: mpsc::UnboundedReceiver, + client_id: String, + ) -> Infallible { + let ping_ticker = tokio::time::interval(Self::HEARTBEAT_INTERVAL); + let backoff = Backoff::new(INITIAL_BACKOFF, MAX_BACKOFF); + + let mut worker = Self { + ws_url, + on_response, + on_state_change, + internal_receiver: UnboundedReceiverStream::new(internal_receiver).fuse(), + ping_ticker, + session_id: SessionId::new(Uuid::new_v4()), + connection_count: 0, + backoff, + }; + + let mut last_close_reason = "InitialConnect".to_string(); + let mut max_observed_timestamp = None; + if let Some(state_change_sender) = &worker.on_state_change { + let _ = state_change_sender.try_send(WebSocketState::Connecting); + } + loop { + let exit_result = worker + .work(last_close_reason, max_observed_timestamp, &client_id) + .await; + + if let Some(state_change_sender) = &worker.on_state_change { + let _ = state_change_sender.try_send(WebSocketState::Connecting); + } + + let e = match exit_result { + Ok(reconnect) => { + // WS worker exited cleanly because it got a request to reconnect + tracing::debug!("Reconnecting websocket due to {}", reconnect.reason); + last_close_reason = reconnect.reason; + max_observed_timestamp = reconnect.max_observed_timestamp; + continue; + }, + Err(e) => e, + }; + last_close_reason = e.to_string(); + let mut delay = worker.backoff.fail(&mut rand::rng()); + tracing::error!( + "Convex WebSocketWorker failed: {e:?}. Backing off for {delay:?} and retrying." + ); + + // Tell the worker that we've failed so it can coordinate the reconnect. + // The worker will send a Reconnect message and the new query set all together. + // Drain the input request queue until we get that reconnect message - which + // will be followed by the refreshed query set. + let _ = worker.on_response.send(ProtocolResponse::Failure).await; + tracing::debug!("Waiting for base client to acknowledge reconnect"); + let reconnect = loop { + let request = worker.internal_receiver.next().await; + // TODO: There is a potential issue where we have multiple queued reconnect + // requests in which case max_observed_timestamp might be lower than actually + // observed. This is fine since it will never cause errors. Will can fix this + // when we restructure the wider protocol to be a single routine. + if let Some(WebSocketRequest::Reconnect(reconnect)) = request { + max_observed_timestamp = reconnect.max_observed_timestamp; + break reconnect; + } + }; + if reconnect.auth_retry { + // Auth retries are paced by the client worker while it polls + // the token callback. A second exponential backoff here can + // leave an already refreshed credential idle for 15 seconds. + worker.backoff.reset(); + delay = Duration::ZERO; + } + tracing::debug!( + "Base client acknowledged reconnect. Sleeping {delay:?} and reconnecting" + ); + tokio::time::sleep(delay).await; + tracing::debug!("Reconnecting"); + } + } + + async fn work( + &mut self, + last_close_reason: String, + max_seen_transition: Option, + client_id: &str, + ) -> anyhow::Result { + let verb = if self.connection_count == 0 { + "connect" + } else { + "reconnect" + }; + tracing::debug!("trying to {verb} to {}", self.ws_url); + let mut internal = WebSocketInternal::new( + self.ws_url.clone(), + self.session_id, + self.connection_count, + last_close_reason, + max_seen_transition, + client_id, + ) + .await?; + // One client owns one session ID and advances the connection count for + // every socket that opens, including client-requested reconnects. + self.connection_count += 1; + tracing::debug!("completed websocket {verb} to {}", self.ws_url); + if let Some(state_change_sender) = &self.on_state_change { + let _ = state_change_sender.try_send(WebSocketState::Connected); + } + + loop { + select_biased! { + _ = self.ping_ticker.tick().fuse() => { + let now = Instant::now(); + if now - internal.last_server_response > Self::SERVER_INACTIVITY_THRESHOLD { + anyhow::bail!("InactiveServer"); + } + }, + server_msg = internal.ws_stream.select_next_some() => { + internal.last_server_response = Instant::now(); + + match server_msg.context("WebsocketConnectionError")? { + Message::Close(close_frame) => { + let close_frame = close_frame.context("CloseMessageWithoutFrame")?; + tracing::debug!("Close frame {close_frame}"); + anyhow::bail!("{}", close_frame.reason); + }, + Message::Text(t) => { + let json: serde_json::Value = serde_json::from_str(&t).context("JsonDeserializeError")?; + let server_message = json.try_into()?; + match server_message { + ServerMessage::Ping => tracing::trace!("received message {server_message:?}"), + _ => tracing::trace!("received message {server_message:?}"), + }; + + let resp = ProtocolResponse::ServerMessage(server_message); + let _ = self.on_response.send(resp).await; + + // TODO: Similar to JS, we should ideally only reset backoff if we get + // the client gets into a correct state, where we have Connected and + // received a response to our pending Queries and Mutations. + self.backoff.reset(); + }, + Message::Ping(_) => { + tracing::trace!("received Ping"); + } + server_msg => { + tracing::debug!("received unknown message {server_msg:?}"); + }, + } + }, + request = self.internal_receiver.select_next_some() => { + match request { + WebSocketRequest::SendMessage(message, sender) => { + tracing::debug!("Sending {message:?}"); + let msg = Message::Text(serde_json::Value::try_from(message).context("JsonSerializeError")?.to_string().into()); + internal.send_worker(msg.clone()).await?; + let _ = sender.send(()); + }, + WebSocketRequest::Reconnect(reason) => return Ok(reason), + }; + } + }; + } + } +} + +fn deprecation_message(headers: &HeaderMap) -> Option { + let dep_state = headers.get(DEPRECATION_STATE_HEADER_NAME)?.to_str().ok()?; + let msg = headers.get(DEPRECATION_MSG_HEADER_NAME)?.to_str().ok()?; + Some(format!("{dep_state}: {msg}")) +} + +impl WebSocketInternal { + async fn new( + ws_url: Url, + session_id: SessionId, + connection_count: u32, + last_close_reason: String, + max_observed_timestamp: Option, + client_id: &str, + ) -> anyhow::Result { + let mut request = (&ws_url).into_client_request().context("Bad WS Url")?; + request.headers_mut().insert( + "Convex-Client", + client_id.try_into().context("Bad client id")?, + ); + let (ws_stream, response) = connect_async(request).await.map_err(|e| { + if let tungstenite::Error::Http(ref response) = e { + let body = response + .body() + .as_deref() + .map(String::from_utf8_lossy) + .unwrap_or_default(); + return anyhow::anyhow!("Connection to {ws_url} failed: {e}: {body}"); + } + anyhow::anyhow!("Connection to {ws_url} failed: {e}") + })?; + + if let Some(msg) = deprecation_message(response.headers()) { + tracing::warn!("{msg}"); + } + + let last_server_response = Instant::now(); + let mut internal = WebSocketInternal { + ws_stream, + last_server_response, + }; + + // Send an initial connect message on the new websocket + let message = ClientMessage::Connect { + session_id, + connection_count, + last_close_reason, + max_observed_timestamp, + client_ts: Some(0), + }; + let msg = Message::Text( + serde_json::Value::try_from(message) + .context("JSONSerializationErrorOnConnect")? + .to_string() + .into(), + ); + internal.send_worker(msg).await?; + + Ok(internal) + } + + async fn send_worker(&mut self, message: Message) -> anyhow::Result<()> { + self.ws_stream + .send(message) + .await + .context("WebsocketClosedOnSend") + } +} diff --git a/third_party/convex_rs/src/value/export/mod.rs b/third_party/convex_rs/src/value/export/mod.rs new file mode 100644 index 00000000..8e43ede2 --- /dev/null +++ b/third_party/convex_rs/src/value/export/mod.rs @@ -0,0 +1,191 @@ +use serde_json::{ + json, + Value as JsonValue, +}; + +use crate::Value; + +#[cfg(any(test, feature = "testing"))] +pub mod roundtrip; + +impl Value { + /// Converts this value to a JSON value in the `json` export format. + /// + /// + /// It is possible for distinct Convex values to be serialized to the same + /// JSON value by this method. For instance, strings and binary values are + /// both exported as JSON strings. However, it is possible to convert the + /// exported value back to a unique Convex value if you also have the `Type` + /// value associated with the original Convex value (see `roundtrip.rs`). + /// + /// # Example + /// ``` + /// use convex::Value; + /// use serde_json::{ + /// json, + /// Value as JsonValue, + /// }; + /// + /// let value = Value::Bytes(vec![0b00000000, 0b00010000, 0b10000011]); + /// assert_eq!(JsonValue::from(value.clone()), json!({ "$bytes": "ABCD" })); + /// assert_eq!(value.export(), json!("ABCD")); + /// ``` + pub fn export(self) -> JsonValue { + match self { + Value::Null => JsonValue::Null, + Value::Int64(value) => JsonValue::String(value.to_string()), + Value::Float64(value) => { + if value.is_nan() { + json!("NaN") + } else if value.is_infinite() { + if value.is_sign_positive() { + json!("Infinity") + } else { + json!("-Infinity") + } + } else { + value.into() + } + }, + Value::Boolean(value) => JsonValue::Bool(value), + Value::String(value) => JsonValue::String(value), + Value::Bytes(value) => JsonValue::String(base64::encode(value)), + Value::Array(values) => { + JsonValue::Array(values.into_iter().map(|x| x.export()).collect()) + }, + Value::Object(map) => JsonValue::Object( + map.into_iter() + .map(|(key, value)| (key, value.export())) + .collect(), + ), + } + } +} + +#[cfg(test)] +mod tests { + use maplit::btreemap; + use serde_json::json; + + use super::*; + + #[test] + fn export_rustdoc_example() { + let value = Value::Bytes(vec![0b00000000, 0b00010000, 0b10000011]); + assert_eq!(JsonValue::from(value.clone()), json!({ "$bytes": "ABCD" })); + assert_eq!(value.export(), json!("ABCD")); + } + + #[test] + fn nulls_are_exported_as_null() { + assert_eq!(Value::Null.export(), JsonValue::Null) + } + + #[test] + fn booleans_are_exported_as_booleans() { + assert_eq!(Value::Boolean(true).export(), json!(true)); + assert_eq!(Value::Boolean(false).export(), json!(false)); + } + + #[test] + fn ints_are_exported_as_strings() { + assert_eq!(Value::Int64(1234).export(), json!("1234")); + + assert_eq!(Value::Int64(-314).export(), json!("-314")); + + assert_eq!(Value::Int64(0).export(), json!("0")); + + assert_eq!( + Value::Int64(i64::MIN).export(), + json!("-9223372036854775808") + ); + + assert_eq!( + Value::Int64(i64::MAX).export(), + json!("9223372036854775807") + ); + } + + #[test] + fn finite_floats_are_exported_as_numbers() { + assert_eq!(Value::Float64(12.34).export(), json!(12.34)); + } + + #[test] + fn pos_zero_is_exported_as_number() { + let json = Value::Float64(0.0).export(); + assert_eq!(json, json!(0.0)); + assert!(json.as_f64().unwrap().is_sign_positive()); + assert!(!json.as_f64().unwrap().is_sign_negative()); + } + + #[test] + fn neg_zero_is_exported_as_number() { + let json = Value::Float64(-0.0).export(); + assert_eq!(json, json!(-0.0)); + assert!(json.as_f64().unwrap().is_sign_negative()); + assert!(!json.as_f64().unwrap().is_sign_positive()); + } + + #[test] + fn infinite_floats_are_exported_as_strings() { + assert_eq!(Value::Float64(f64::INFINITY).export(), json!("Infinity")); + assert_eq!( + Value::Float64(f64::NEG_INFINITY).export(), + json!("-Infinity") + ); + } + + #[test] + fn nan_is_exported_as_string() { + assert_eq!(Value::Float64(f64::NAN).export(), json!("NaN")); + } + + #[test] + fn strings_are_exported_as_strings() { + assert_eq!(Value::Null.export(), JsonValue::Null); + } + + #[test] + fn bytes_are_exported_as_base64() { + let vec: Vec = vec![ + 0b00000000, 0b00010000, 0b10000011, 0b00010000, 0b01010001, 0b10000111, 0b00100000, + 0b10010010, 0b10001011, 0b00110000, 0b11010011, 0b10001111, 0b01000001, 0b00010100, + 0b10010011, 0b01010001, 0b01010101, 0b10010111, 0b01100001, 0b10010110, 0b10011011, + 0b01110001, 0b11010111, 0b10011111, 0b10000010, 0b00011000, 0b10100011, 0b10010010, + 0b01011001, 0b10100111, 0b10100010, 0b10011010, 0b10101011, 0b10110010, 0b11011011, + 0b10101111, 0b11000011, 0b00011100, 0b10110011, 0b11010011, 0b01011101, 0b10110111, + 0b11100011, 0b10011110, 0b10111011, 0b11110011, 0b11011111, 0b10111111, 0b00000000, + ]; + + assert_eq!( + Value::Bytes(vec).export(), + json!("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/AA==") + ); + } + + #[test] + fn arrays_are_exported_as_arrays() { + assert_eq!( + Value::Array(vec![Value::Int64(1), Value::Int64(2), Value::Int64(3)]).export(), + json!(["1", "2", "3"]), + ); + } + + #[test] + fn objects_are_exported_as_objects() { + assert_eq!( + Value::Object(btreemap! { + "a".to_string() => 1.into(), + "b".to_string() => 2.into(), + "c".to_string() => 3.into(), + }) + .export(), + json!({ + "a": "1", + "b": "2", + "c": "3", + }), + ); + } +} diff --git a/third_party/convex_rs/src/value/export/roundtrip.rs b/third_party/convex_rs/src/value/export/roundtrip.rs new file mode 100644 index 00000000..1ff1307f --- /dev/null +++ b/third_party/convex_rs/src/value/export/roundtrip.rs @@ -0,0 +1,169 @@ +use std::collections::BTreeMap; + +use anyhow::Context; +use serde_json::Value as JsonValue; + +use crate::Value; + +/// Type hint associated with a Convex value. This allows us to uniquely convert +/// the exported value back to the original Convex value. +#[allow(missing_docs)] +pub enum ExportContext { + Null, + Int64, + Float64 { + // Store the f64 value in the export context when it is NaN, because the export format + // assumes a single NaN value. This ensures that we can fully roundtrip values. + nan_value: Option, + }, + Boolean, + String, + Bytes, + Array(Vec), + Set, + Map, + Object(BTreeMap), +} + +impl ExportContext { + /// Returns the export context of a Convex value + pub fn of(value: &Value) -> ExportContext { + match value { + Value::Null => ExportContext::Null, + Value::Int64(_) => ExportContext::Int64, + Value::Float64(f) => ExportContext::Float64 { + nan_value: f.is_nan().then_some(*f), + }, + Value::Boolean(_) => ExportContext::Boolean, + Value::String(_) => ExportContext::String, + Value::Bytes(_) => ExportContext::Bytes, + Value::Array(elements) => { + ExportContext::Array(elements.iter().map(ExportContext::of).collect()) + }, + Value::Object(fields) => ExportContext::Object( + fields + .iter() + .map(|(key, value)| (key.clone(), ExportContext::of(value))) + .collect(), + ), + } + } +} + +impl TryFrom<(JsonValue, &ExportContext)> for Value { + type Error = anyhow::Error; + + fn try_from( + (exported_value, type_hint): (JsonValue, &ExportContext), + ) -> Result { + match type_hint { + ExportContext::Null => Ok(Value::Null), + ExportContext::Int64 => match exported_value { + JsonValue::String(str) => str + .parse::() + .map(Value::from) + .context("Unexpected string for i64"), + _ => anyhow::bail!("Unexpected value for i64"), + }, + ExportContext::Float64 { + nan_value: Some(nan_value), + } => { + if !nan_value.is_nan() { + anyhow::bail!("Unexpected non-NaN value in the export context"); + } + + if exported_value != JsonValue::String(String::from("NaN")) { + anyhow::bail!("Unexpected serialization of a NaN value"); + } + + Ok((*nan_value).into()) + }, + ExportContext::Float64 { nan_value: None } => match exported_value { + JsonValue::String(str) => match str.as_ref() { + "Infinity" => Ok(f64::INFINITY.into()), + "-Infinity" => Ok(f64::NEG_INFINITY.into()), + _ => anyhow::bail!("Unexpected string for f64"), + }, + JsonValue::Number(n) => n + .as_f64() + .map(Value::from) + .context("Unexpected number for i64"), + _ => anyhow::bail!("Unexpected value for f64"), + }, + ExportContext::Boolean => match exported_value { + JsonValue::Bool(value) => Ok(value.into()), + _ => anyhow::bail!("Unexpected value for boolean"), + }, + ExportContext::String => match exported_value { + JsonValue::String(value) => Ok(value.into()), + _ => anyhow::bail!("Unexpected value for string"), + }, + ExportContext::Bytes => match exported_value { + JsonValue::String(value) => base64::decode(value) + .map(Value::from) + .context("Unexpected string for bytes"), + _ => anyhow::bail!("Unexpected value for bytes"), + }, + ExportContext::Array(type_hints) => match exported_value { + JsonValue::Array(exported_values) => { + if exported_values.len() != type_hints.len() { + anyhow::bail!("Array lengths do not match"); + } + + let values: anyhow::Result> = exported_values + .into_iter() + .zip(type_hints) + .map(Value::try_from) + .collect(); + + Ok(Value::Array(values?)) + }, + _ => anyhow::bail!("Unexpected value for array"), + }, + ExportContext::Set | ExportContext::Map => Value::try_from(exported_value) + .context("Couldn’t deserialize set/map from internal representation"), + ExportContext::Object(type_hints) => match exported_value { + JsonValue::Object(exported_values) => { + let entries: anyhow::Result> = exported_values + .into_iter() + .map(|(key, value)| { + let Some(type_hint) = type_hints.get(&key) else { + anyhow::bail!("Missing export context for an object key"); + }; + Ok((key, (value, type_hint).try_into()?)) + }) + .collect(); + + Ok(Value::Object(entries?)) + }, + _ => anyhow::bail!("Unexpected value for object"), + }, + } + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use crate::{ + value::export::roundtrip::ExportContext, + Value, + }; + + proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: None, ..ProptestConfig::default() + })] + #[test] + fn export_roundtrips_with_type_hint(value in any::()) { + let exported_value = value.clone().export(); + let type_hint = ExportContext::of(&value); + + prop_assert_eq!( + value, + Value::try_from((exported_value, &type_hint)).unwrap() + ); + } + } +} diff --git a/third_party/convex_rs/src/value/json/bytes.rs b/third_party/convex_rs/src/value/json/bytes.rs new file mode 100644 index 00000000..45d4f899 --- /dev/null +++ b/third_party/convex_rs/src/value/json/bytes.rs @@ -0,0 +1,14 @@ +/// Helper functions for encoding `Bytes`s as `String`s. +pub enum JsonBytes {} + +impl JsonBytes { + /// Encode a binary string as a string. + pub fn encode(bytes: &Vec) -> String { + base64::encode(&bytes[..]) + } + + /// Decode a binary string from a string. + pub fn decode(s: String) -> anyhow::Result> { + Ok(base64::decode(s.as_bytes())?) + } +} diff --git a/third_party/convex_rs/src/value/json/float.rs b/third_party/convex_rs/src/value/json/float.rs new file mode 100644 index 00000000..6777eb20 --- /dev/null +++ b/third_party/convex_rs/src/value/json/float.rs @@ -0,0 +1,19 @@ +use anyhow::anyhow; + +/// Helper functions for encoding `f64`s as `String`s. +pub enum JsonFloat {} + +impl JsonFloat { + /// Encode an `f64` as a string. + pub fn encode(n: f64) -> String { + base64::encode(n.to_le_bytes()) + } + + /// Decode an `f64` from a string. + pub fn decode(s: String) -> anyhow::Result { + let bytes: [u8; 8] = base64::decode(s.as_bytes())? + .try_into() + .map_err(|_| anyhow!("Float64 must be exactly eight bytes"))?; + Ok(f64::from_le_bytes(bytes)) + } +} diff --git a/third_party/convex_rs/src/value/json/integer.rs b/third_party/convex_rs/src/value/json/integer.rs new file mode 100644 index 00000000..15f914c5 --- /dev/null +++ b/third_party/convex_rs/src/value/json/integer.rs @@ -0,0 +1,19 @@ +use anyhow::anyhow; + +/// Helper functions for encoding `Int64`s as `String`s. +pub enum JsonInteger {} + +impl JsonInteger { + /// Encode an integer as a string. + pub fn encode(n: i64) -> String { + base64::encode(n.to_le_bytes()) + } + + /// Decode an integer from a string. + pub fn decode(s: String) -> anyhow::Result { + let bytes: [u8; 8] = base64::decode(s.as_bytes())? + .try_into() + .map_err(|_| anyhow!("Int64 must be exactly eight bytes"))?; + Ok(i64::from_le_bytes(bytes)) + } +} diff --git a/third_party/convex_rs/src/value/json/mod.rs b/third_party/convex_rs/src/value/json/mod.rs new file mode 100644 index 00000000..18d8d589 --- /dev/null +++ b/third_party/convex_rs/src/value/json/mod.rs @@ -0,0 +1,157 @@ +use std::{ + cmp::Ordering, + collections::BTreeMap, + num::FpCategory, +}; + +use anyhow::Context; +use serde_json::{ + json, + Value as JsonValue, +}; + +use crate::value::Value; + +mod bytes; +mod float; +mod integer; + +/// Is a floating point number native zero? +fn is_negative_zero(n: f64) -> bool { + matches!(n.total_cmp(&-0.0), Ordering::Equal) +} + +impl From for JsonValue { + fn from(value: Value) -> JsonValue { + match value { + Value::Null => JsonValue::Null, + Value::Int64(n) => json!({ "$integer": integer::JsonInteger::encode(n) }), + Value::Float64(n) => { + let mut is_special = is_negative_zero(n); + is_special |= match n.classify() { + FpCategory::Zero | FpCategory::Normal | FpCategory::Subnormal => false, + FpCategory::Infinite | FpCategory::Nan => true, + }; + if is_special { + json!({ "$float": float::JsonFloat::encode(n) }) + } else { + json!(n) + } + }, + Value::Boolean(b) => json!(b), + Value::String(s) => json!(s), + Value::Bytes(b) => json!({ "$bytes": bytes::JsonBytes::encode(&b) }), + Value::Array(a) => JsonValue::from(a), + Value::Object(o) => o.into_iter().collect(), + } + } +} + +impl TryFrom for Value { + type Error = anyhow::Error; + + fn try_from(value: JsonValue) -> anyhow::Result { + let r = match value { + JsonValue::Null => Self::Null, + JsonValue::Bool(b) => Self::from(b), + JsonValue::Number(n) => { + // TODO: JSON supports arbitrary precision numbers? + let n = n + .as_f64() + .context("Arbitrary precision JSON integers unsupported")?; + Value::from(n) + }, + JsonValue::String(s) => Self::from(s), + JsonValue::Array(arr) => { + let mut out = Vec::with_capacity(arr.len()); + for a in arr { + out.push(Value::try_from(a)?); + } + Value::Array(out) + }, + JsonValue::Object(map) => { + if map.len() == 1 { + let (key, value) = map.into_iter().next().unwrap(); + match &key[..] { + "$bytes" => { + let i: String = serde_json::from_value(value)?; + Self::Bytes(bytes::JsonBytes::decode(i)?) + }, + "$integer" => { + let i: String = serde_json::from_value(value)?; + Self::from(integer::JsonInteger::decode(i)?) + }, + "$float" => { + let i: String = serde_json::from_value(value)?; + let n = float::JsonFloat::decode(i)?; + // Float64s encoded as a $float object must not fit into a regular + // `number`. + if !is_negative_zero(n) { + if let FpCategory::Normal | FpCategory::Subnormal = n.classify() { + anyhow::bail!("Float64 {} should be encoded as a number", n); + } + } + Self::from(n) + }, + "$set" => { + anyhow::bail!( + "Received a Set which is no longer supported as a Convex type, \ + with values: {value}" + ); + }, + "$map" => { + anyhow::bail!( + "Received a Map which is no longer supported as a Convex type, \ + with values: {value}" + ); + }, + _ => { + let mut fields = BTreeMap::new(); + fields.insert(key, Self::try_from(value)?); + Self::Object(fields) + }, + } + } else { + let mut fields = BTreeMap::new(); + for (key, value) in map { + fields.insert(key, Self::try_from(value)?); + } + Self::Object(fields) + } + }, + }; + Ok(r) + } +} + +#[cfg(test)] +mod tests { + use convex_sync_types::testing::assert_roundtrips; + use proptest::prelude::*; + use serde_json::Value as JsonValue; + + use crate::Value; + + proptest! { + #![proptest_config( + ProptestConfig { failure_persistence: None, ..ProptestConfig::default() } + )] + + #[test] + fn test_value_roundtrips(value in any::()) { + assert_roundtrips::(value); + } + } + + #[test] + fn test_value_roundtrips_trophies() { + let trophies = vec![ + Value::Float64(1.0), + Value::Float64(f64::NAN), + Value::Array(vec![Value::Float64(f64::NAN)]), + ]; + for trophy in trophies { + assert_roundtrips::(trophy); + } + } +} diff --git a/third_party/convex_rs/src/value/mod.rs b/third_party/convex_rs/src/value/mod.rs new file mode 100644 index 00000000..681e141d --- /dev/null +++ b/third_party/convex_rs/src/value/mod.rs @@ -0,0 +1,136 @@ +use std::collections::BTreeMap; + +pub mod export; +mod json; +mod sorting; +use thiserror::Error; + +/// A value that can be passed as an argument or returned from Convex functions. +/// They correspond to the [supported Convex types](https://docs.convex.dev/database/types). +#[derive(Clone, Debug)] +#[allow(missing_docs)] +pub enum Value { + Null, + Int64(i64), + Float64(f64), + Boolean(bool), + String(String), + Bytes(Vec), + Array(Vec), + Object(BTreeMap), +} + +impl> From> for Value { + fn from(v: Option) -> Value { + v.map(|v| v.into()).unwrap_or(Value::Null) + } +} + +impl From for Value { + fn from(v: i64) -> Value { + Value::Int64(v) + } +} + +impl From for Value { + fn from(v: f64) -> Value { + Value::Float64(v) + } +} + +impl From for Value { + fn from(v: bool) -> Value { + Value::Boolean(v) + } +} + +impl From<&str> for Value { + fn from(v: &str) -> Value { + Value::String(v.into()) + } +} + +impl From for Value { + fn from(v: String) -> Value { + Value::String(v) + } +} + +impl From> for Value { + fn from(v: Vec) -> Value { + Value::Bytes(v) + } +} + +impl From> for Value { + fn from(v: Vec) -> Value { + Value::Array(v) + } +} + +#[cfg(any(test, feature = "testing"))] +mod proptest { + use proptest::prelude::*; + + use super::Value; + + impl Arbitrary for Value { + type Parameters = (); + type Strategy = proptest::strategy::BoxedStrategy; + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + value_strategy(4, 32, 8).boxed() + } + } + + fn value_strategy( + depth: usize, + node_target: usize, + branching: usize, + ) -> impl Strategy { + // https://altsysrq.github.io/proptest-book/proptest/tutorial/recursive.html + let leaf = prop_oneof![ + 1 => Just(Value::Null), + 1 => any::().prop_map(Value::from), + 1 => (prop::num::f64::ANY | prop::num::f64::SIGNALING_NAN).prop_map(Value::from), + 1 => any::().prop_map(Value::from), + 1 => any::().prop_map(Value::String), + 1 => any::>().prop_map(Value::Bytes), + ]; + leaf.prop_recursive( + depth as u32, + node_target as u32, + branching as u32, + move |inner| { + prop_oneof![ + // Manually create the strategies here rather than using the `Arbitrary` + // implementations on `Array`, etc. This lets us explicitly pass `inner` + // through rather than starting the `Value` strategy from + // scratch at each tree level. + prop::collection::vec(inner.clone(), 0..branching).prop_map(Value::Array), + prop::collection::btree_map(any::(), inner, 0..branching) + .prop_map(Value::Object), + ] + }, + ) + } +} + +/// An application error that can be returned from Convex functions. To learn +/// more about throwing custom application errors, see [Convex Errors](https://docs.convex.dev/functions/error-handling/application-errors#throwing-application-errors). +#[derive(Error, Clone, PartialEq, Eq)] +#[error("{:}", message)] +pub struct ConvexError { + /// From any error, redacted from prod deployments. + pub message: String, + /// Custom application error data payload that can be passed from your + /// function to a client. + pub data: Value, +} + +impl std::fmt::Debug for ConvexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = &self.message; + write!(f, "{message:#?}") + } +} diff --git a/third_party/convex_rs/src/value/sorting.rs b/third_party/convex_rs/src/value/sorting.rs new file mode 100644 index 00000000..5990f205 --- /dev/null +++ b/third_party/convex_rs/src/value/sorting.rs @@ -0,0 +1,75 @@ +//! Implementation of `Ord` and `Eq` for `Value` that works around limitations +//! of f64 by using a `TotalOrdF64` type. + +use std::{ + cmp::Ordering, + collections::BTreeMap, +}; + +use crate::value::Value; + +#[derive(Eq, PartialEq, Ord, PartialOrd)] +enum OrdValue<'a> { + Null, + Int64(i64), + Float64(TotalOrdF64), + Boolean(bool), + String(&'a String), + Bytes(&'a Vec), + Array(&'a Vec), + Object(&'a BTreeMap), +} + +impl<'a> From<&'a Value> for OrdValue<'a> { + fn from(v: &'a Value) -> OrdValue<'a> { + match v { + Value::Null => OrdValue::Null, + Value::Int64(x) => OrdValue::Int64(*x), + Value::Float64(x) => OrdValue::Float64(TotalOrdF64(*x)), + Value::Boolean(x) => OrdValue::Boolean(*x), + Value::String(x) => OrdValue::String(x), + Value::Bytes(x) => OrdValue::Bytes(x), + Value::Array(x) => OrdValue::Array(x), + Value::Object(x) => OrdValue::Object(x), + } + } +} + +#[derive(Clone, Debug)] +struct TotalOrdF64(f64); + +impl Ord for TotalOrdF64 { + fn cmp(&self, other: &Self) -> Ordering { + self.0.total_cmp(&other.0) + } +} +impl PartialOrd for TotalOrdF64 { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl PartialEq for TotalOrdF64 { + fn eq(&self, other: &Self) -> bool { + matches!(self.cmp(other), Ordering::Equal) + } +} +impl Eq for TotalOrdF64 {} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} +impl Eq for Value {} + +impl PartialOrd for Value { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Value { + fn cmp(&self, other: &Self) -> Ordering { + OrdValue::from(self).cmp(&OrdValue::from(other)) + } +} diff --git a/tool/audit_convex_contract.mjs b/tool/audit_convex_contract.mjs new file mode 100644 index 00000000..f4f16a49 --- /dev/null +++ b/tool/audit_convex_contract.mjs @@ -0,0 +1,186 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const toolDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(toolDirectory, ".."); +const functionSpec = JSON.parse( + readFileSync(resolve(repositoryRoot, "convex/function_spec.json"), "utf8"), +); +const snapshottedErrorCodes = JSON.parse( + readFileSync(resolve(repositoryRoot, "convex/error_codes.json"), "utf8"), +); +const errorsSource = readFileSync( + resolve(repositoryRoot, "convex/lib/errors.ts"), + "utf8", +); + +const failures = []; +const supportedValidatorTypes = new Set([ + "array", + "bigint", + "boolean", + "bytes", + "id", + "literal", + "null", + "number", + "object", + "record", + "string", + "union", +]); +const allowedPublicIds = new Set([ + "images.js:completeUpload.args.storageId:_storage", +]); + +function fail(message) { + failures.push(message); +} + +function auditValidator(validator, path, identifier) { + if (validator === null || typeof validator !== "object") { + fail(`${identifier}.${path.join(".")} is not a validator object`); + return; + } + if (!supportedValidatorTypes.has(validator.type)) { + fail( + `${identifier}.${path.join(".")} uses unsupported validator ${String(validator.type)}`, + ); + return; + } + + switch (validator.type) { + case "array": + auditValidator(validator.value, [...path, "item"], identifier); + break; + case "object": + for (const [fieldName, field] of Object.entries(validator.value)) { + if ( + field === null || + typeof field !== "object" || + typeof field.optional !== "boolean" || + field.fieldType === undefined + ) { + fail(`${identifier}.${[...path, fieldName].join(".")} has an invalid field`); + continue; + } + auditValidator(field.fieldType, [...path, fieldName], identifier); + } + break; + case "record": + auditValidator(validator.keys, [...path, "key"], identifier); + if ( + validator.values === null || + typeof validator.values !== "object" || + validator.values.optional !== false || + validator.values.fieldType === undefined + ) { + fail(`${identifier}.${path.join(".")} has invalid record values`); + } else { + auditValidator( + validator.values.fieldType, + [...path, "value"], + identifier, + ); + } + break; + case "union": + if (!Array.isArray(validator.value) || validator.value.length === 0) { + fail(`${identifier}.${path.join(".")} has an empty union`); + } else { + validator.value.forEach((member, index) => + auditValidator(member, [...path, `union${index}`], identifier), + ); + } + break; + case "id": { + const key = `${identifier}.${path.join(".")}:${validator.tableName}`; + if (!allowedPublicIds.has(key)) { + fail(`${identifier}.${path.join(".")} exposes Convex id ${validator.tableName}`); + } + break; + } + } +} + +if ( + functionSpec === null || + typeof functionSpec !== "object" || + !Array.isArray(functionSpec.functions) || + Object.keys(functionSpec).some((key) => key !== "functions") +) { + fail("function_spec.json must contain only a functions array"); +} else { + const identifiers = new Set(); + for (const functionSpecEntry of functionSpec.functions) { + if (identifiers.has(functionSpecEntry.identifier)) { + fail(`duplicate function identifier ${functionSpecEntry.identifier}`); + } + identifiers.add(functionSpecEntry.identifier); + if (functionSpecEntry.visibility?.kind !== "public") { + continue; + } + if (functionSpecEntry.args === null) { + fail(`${functionSpecEntry.identifier} is missing an args validator`); + } else { + auditValidator(functionSpecEntry.args, ["args"], functionSpecEntry.identifier); + if (functionSpecEntry.functionType === "Mutation") { + const protocolField = functionSpecEntry.args.value?.clientProtocolVersion; + if ( + functionSpecEntry.args.type !== "object" || + protocolField?.optional !== false || + protocolField?.fieldType?.type !== "number" + ) { + fail( + `${functionSpecEntry.identifier} must require numeric clientProtocolVersion`, + ); + } + } + } + if (functionSpecEntry.returns === null) { + fail(`${functionSpecEntry.identifier} is missing a return validator`); + } else { + auditValidator( + functionSpecEntry.returns, + ["returns"], + functionSpecEntry.identifier, + ); + } + } +} + +const errorCodesDeclaration = errorsSource.match( + /export const errorCodes\s*=\s*\[([\s\S]*?)\]\s*as const/, +); +if (errorCodesDeclaration === null) { + fail("convex/lib/errors.ts does not export errorCodes as a const array"); +} else { + const sourceCodes = [ + ...errorCodesDeclaration[1].matchAll(/"([A-Z][A-Z0-9_]*)"/g), + ].map((match) => match[1]); + const sortedSourceCodes = [...new Set(sourceCodes)].sort(); + if (sourceCodes.length !== sortedSourceCodes.length) { + fail("errorCodes contains duplicate values"); + } + if (JSON.stringify(sourceCodes) !== JSON.stringify(sortedSourceCodes)) { + fail("errorCodes must be sorted"); + } + if ( + JSON.stringify(snapshottedErrorCodes) !== JSON.stringify(sortedSourceCodes) + ) { + fail("error_codes.json does not match convex/lib/errors.ts"); + } +} + +if (failures.length > 0) { + console.error(failures.map((failure) => `- ${failure}`).join("\n")); + process.exitCode = 1; +} else { + const publicFunctionCount = functionSpec.functions.filter( + (entry) => entry.visibility?.kind === "public", + ).length; + console.log( + `Convex contract audit passed: ${publicFunctionCount} public functions, ${snapshottedErrorCodes.length} error codes.`, + ); +} diff --git a/tool/check_convex_web_binding.mjs b/tool/check_convex_web_binding.mjs new file mode 100644 index 00000000..5a3b4916 --- /dev/null +++ b/tool/check_convex_web_binding.mjs @@ -0,0 +1,138 @@ +// Checks that every JS member the web Convex binding declares exists in the +// Convex browser bundle the web build ships. +// +// lib/collab/src/convex_client_web.dart binds the bundle through +// `dart:js_interop` extension types. A member the bundle lacks compiles fine +// and only throws at runtime in the browser (a web build once called +// `clearAuth` on the browser ConvexClient, which has none). This script reads +// the `external` members from the Dart source, builds a real ConvexClient from +// web/convex.browser.bundle.js over a stub WebSocket (no network), and fails +// if any member is missing. +// +// Run: node tool/check_convex_web_binding.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const bindingPath = path.join(root, 'lib/collab/src/convex_client_web.dart'); +const bundlePath = path.join(root, 'web/convex.browser.bundle.js'); + +/** The `external` members of each extension type, and its `@JS` name. */ +function readBinding(source) { + const types = new Map(); + const typePattern = + /(?:@JS\(\s*(?:'([^']*)')?\s*\)\s*)?extension type (\w+)\._\([^)]*\)[^{]*\{([\s\S]*?)\n\}/g; + for (const [, jsName, typeName, body] of source.matchAll(typePattern)) { + const members = []; + for (const line of body.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('external ')) continue; + if (/^external factory\b/.test(trimmed)) continue; // checked via jsName + const getter = trimmed.match(/\bget\s+(\w+)\s*;/); + const operator = trimmed.match(/\boperator\s*(\S+?)\s*\(/); + const method = trimmed.match(/(\w+)\s*\(/); + if (getter) members.push(getter[1]); + else if (operator) members.push(`operator ${operator[1]}`); + else if (method) members.push(method[1]); + } + types.set(typeName, { jsName, members }); + } + return types; +} + +/** A WebSocket that never connects, so constructing a client stays offline. */ +class StubWebSocket { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + constructor(url) { + this.url = url; + this.readyState = StubWebSocket.CONNECTING; + } + send() {} + close() { + this.readyState = StubWebSocket.CLOSED; + } + addEventListener() {} + removeEventListener() {} +} + +function loadBundle() { + const context = vm.createContext({ + console, + setTimeout, + clearTimeout, + setInterval, + clearInterval, + queueMicrotask, + URL, + TextEncoder, + TextDecoder, + crypto: globalThis.crypto, + fetch: async () => { + throw new Error('network disabled in binding check'); + }, + WebSocket: StubWebSocket, + }); + context.globalThis = context; + vm.runInContext( + `${fs.readFileSync(bundlePath, 'utf8')}\nglobalThis.convex = convex;`, + context, + { filename: bundlePath }, + ); + return context; +} + +const failures = []; +const types = readBinding(fs.readFileSync(bindingPath, 'utf8')); +const context = loadBundle(); + +// How to get a live instance of each bound JS type. A new extension type in +// the binding must be added here, so it cannot go unchecked. +const client = new context.convex.ConvexClient('https://binding-check.convex.cloud'); +const browserBuiltins = new Set(['_JsUint8ArrayView', '_JsReadableUint8Array']); +const instances = { + _JsConvexClient: client, + _JsBaseConvexClient: client.client, + _JsConnectionState: client.connectionState(), +}; + +const constructorPath = types.get('_JsConvexClient')?.jsName; +if (constructorPath !== 'convex.ConvexClient') { + failures.push( + `_JsConvexClient is bound to '${constructorPath}', expected 'convex.ConvexClient'`, + ); +} + +for (const [typeName, { members }] of types) { + if (browserBuiltins.has(typeName)) continue; + const instance = instances[typeName]; + if (instance === undefined) { + failures.push( + `${typeName}: no instance to check. Add it to the table in ` + + 'tool/check_convex_web_binding.mjs.', + ); + continue; + } + for (const member of members) { + if (member.startsWith('operator ')) continue; + if (!(member in instance)) { + failures.push(`${typeName}.${member} does not exist in the Convex browser bundle`); + } else { + console.log(`ok ${typeName}.${member}`); + } + } +} + +// The stub socket never reports closing, so close() is not awaited. +void client.close(); + +if (failures.length > 0) { + console.error('\nConvex web binding does not match web/convex.browser.bundle.js:'); + for (const failure of failures) console.error(` - ${failure}`); + process.exit(1); +} +console.log('\nConvex web binding matches the browser bundle.'); diff --git a/tool/convex_client_gauntlet/.gitignore b/tool/convex_client_gauntlet/.gitignore new file mode 100644 index 00000000..c8bcc777 --- /dev/null +++ b/tool/convex_client_gauntlet/.gitignore @@ -0,0 +1,4 @@ +/.dart_tool/ +/lib/_probe_caller.dart +/lib/_probe_generated/ +/runtime/build/ diff --git a/tool/convex_client_gauntlet/README.md b/tool/convex_client_gauntlet/README.md new file mode 100644 index 00000000..6bb65af5 --- /dev/null +++ b/tool/convex_client_gauntlet/README.md @@ -0,0 +1,20 @@ +# Convex Dart client gauntlet + +This isolated Dart package reproduces the compile-time contract gate declared +for Icarus's Convex client comparison. It pins `dartvex` and +`dartvex_codegen` to 0.2.0 without adding either package to the application. + +Run the repaired compile-time contract gate and its regression test from this +directory: + +```bash +fvm dart pub get +fvm dart run bin/run.dart +fvm dart test +``` + +The gate uses an explicit return schema and an Icarus-owned strict wrapper. The +wrapper rejects Dartvex warnings and public methods that degrade to a +`dynamic` result. The result-rename fixture changes only `publicId` to +`folderPublicId`, so the unchanged caller proves the generated return type at +analysis time. diff --git a/tool/convex_client_gauntlet/analysis_options.yaml b/tool/convex_client_gauntlet/analysis_options.yaml new file mode 100644 index 00000000..84879e80 --- /dev/null +++ b/tool/convex_client_gauntlet/analysis_options.yaml @@ -0,0 +1,4 @@ +analyzer: + exclude: + - lib/_probe_caller.dart + - lib/_probe_generated/** diff --git a/tool/convex_client_gauntlet/bin/run.dart b/tool/convex_client_gauntlet/bin/run.dart new file mode 100644 index 00000000..399e637c --- /dev/null +++ b/tool/convex_client_gauntlet/bin/run.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:icarus_convex_client_gauntlet/contract_gate.dart'; + +Future main(List args) async { + final outputIndex = args.indexOf('--output'); + final outputPath = outputIndex == -1 || outputIndex + 1 >= args.length + ? null + : args[outputIndex + 1]; + final result = await evaluateContractGate(); + final json = '${result.toPrettyJson()}\n'; + stdout.write(json); + if (outputPath != null) { + File(outputPath).writeAsStringSync(json); + } +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_argument_renamed.json b/tool/convex_client_gauntlet/fixtures/folders_argument_renamed.json new file mode 100644 index 00000000..8acd8977 --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_argument_renamed.json @@ -0,0 +1,50 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { + "type": "object", + "value": { + "parentPublicId": { + "fieldType": { "type": "string" }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { "type": "literal", "value": "owned" }, + { "type": "literal", "value": "shared" }, + { "type": "literal", "value": "all" } + ] + }, + "optional": true + } + } + }, + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "publicId": { "fieldType": { "type": "string" }, "optional": false }, + "name": { "fieldType": { "type": "string" }, "optional": false }, + "iconId": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconCodePoint": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconFontFamily": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "iconFontPackage": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "color": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "customColorValue": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "parentFolderPublicId": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "createdAt": { "fieldType": { "type": "number" }, "optional": false }, + "updatedAt": { "fieldType": { "type": "number" }, "optional": false }, + "role": { "fieldType": { "type": "union", "value": [{ "type": "literal", "value": "owner" }, { "type": "literal", "value": "editor" }, { "type": "literal", "value": "viewer" }] }, "optional": false } + } + } + }, + "identifier": "folders.js:listForParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_function_renamed.json b/tool/convex_client_gauntlet/fixtures/folders_function_renamed.json new file mode 100644 index 00000000..f1f04273 --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_function_renamed.json @@ -0,0 +1,50 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { + "type": "object", + "value": { + "parentFolderPublicId": { + "fieldType": { "type": "string" }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { "type": "literal", "value": "owned" }, + { "type": "literal", "value": "shared" }, + { "type": "literal", "value": "all" } + ] + }, + "optional": true + } + } + }, + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "publicId": { "fieldType": { "type": "string" }, "optional": false }, + "name": { "fieldType": { "type": "string" }, "optional": false }, + "iconId": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconCodePoint": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconFontFamily": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "iconFontPackage": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "color": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "customColorValue": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "parentFolderPublicId": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "createdAt": { "fieldType": { "type": "number" }, "optional": false }, + "updatedAt": { "fieldType": { "type": "number" }, "optional": false }, + "role": { "fieldType": { "type": "union", "value": [{ "type": "literal", "value": "owner" }, { "type": "literal", "value": "editor" }, { "type": "literal", "value": "viewer" }] }, "optional": false } + } + } + }, + "identifier": "folders.js:listWithinParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_list_for_parent.json b/tool/convex_client_gauntlet/fixtures/folders_list_for_parent.json new file mode 100644 index 00000000..d424a633 --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_list_for_parent.json @@ -0,0 +1,50 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { + "type": "object", + "value": { + "parentFolderPublicId": { + "fieldType": { "type": "string" }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { "type": "literal", "value": "owned" }, + { "type": "literal", "value": "shared" }, + { "type": "literal", "value": "all" } + ] + }, + "optional": true + } + } + }, + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "publicId": { "fieldType": { "type": "string" }, "optional": false }, + "name": { "fieldType": { "type": "string" }, "optional": false }, + "iconId": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconCodePoint": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconFontFamily": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "iconFontPackage": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "color": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "customColorValue": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "parentFolderPublicId": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "createdAt": { "fieldType": { "type": "number" }, "optional": false }, + "updatedAt": { "fieldType": { "type": "number" }, "optional": false }, + "role": { "fieldType": { "type": "union", "value": [{ "type": "literal", "value": "owner" }, { "type": "literal", "value": "editor" }, { "type": "literal", "value": "viewer" }] }, "optional": false } + } + } + }, + "identifier": "folders.js:listForParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_missing_return.json b/tool/convex_client_gauntlet/fixtures/folders_missing_return.json new file mode 100644 index 00000000..7099ebf5 --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_missing_return.json @@ -0,0 +1,31 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { + "type": "object", + "value": { + "parentFolderPublicId": { + "fieldType": { "type": "string" }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { "type": "literal", "value": "owned" }, + { "type": "literal", "value": "shared" }, + { "type": "literal", "value": "all" } + ] + }, + "optional": true + } + } + }, + "returns": null, + "identifier": "folders.js:listForParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_result_renamed.json b/tool/convex_client_gauntlet/fixtures/folders_result_renamed.json new file mode 100644 index 00000000..f9656a4d --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_result_renamed.json @@ -0,0 +1,50 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { + "type": "object", + "value": { + "parentFolderPublicId": { + "fieldType": { "type": "string" }, + "optional": true + }, + "scope": { + "fieldType": { + "type": "union", + "value": [ + { "type": "literal", "value": "owned" }, + { "type": "literal", "value": "shared" }, + { "type": "literal", "value": "all" } + ] + }, + "optional": true + } + } + }, + "returns": { + "type": "array", + "value": { + "type": "object", + "value": { + "folderPublicId": { "fieldType": { "type": "string" }, "optional": false }, + "name": { "fieldType": { "type": "string" }, "optional": false }, + "iconId": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconCodePoint": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "iconFontFamily": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "iconFontPackage": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "color": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "customColorValue": { "fieldType": { "type": "union", "value": [{ "type": "number" }, { "type": "null" }] }, "optional": false }, + "parentFolderPublicId": { "fieldType": { "type": "union", "value": [{ "type": "string" }, { "type": "null" }] }, "optional": false }, + "createdAt": { "fieldType": { "type": "number" }, "optional": false }, + "updatedAt": { "fieldType": { "type": "number" }, "optional": false }, + "role": { "fieldType": { "type": "union", "value": [{ "type": "literal", "value": "owner" }, { "type": "literal", "value": "editor" }, { "type": "literal", "value": "viewer" }] }, "optional": false } + } + } + }, + "identifier": "folders.js:listForParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/fixtures/folders_unsupported_validator.json b/tool/convex_client_gauntlet/fixtures/folders_unsupported_validator.json new file mode 100644 index 00000000..56f3b87a --- /dev/null +++ b/tool/convex_client_gauntlet/fixtures/folders_unsupported_validator.json @@ -0,0 +1,20 @@ +{ + "url": "https://your-deployment.convex.cloud", + "functions": [ + { + "functionType": "Query", + "args": { "type": "object", "value": {} }, + "returns": { + "type": "object", + "value": { + "futureField": { + "fieldType": { "type": "future-validator" }, + "optional": false + } + } + }, + "identifier": "folders.js:listForParent", + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/convex_client_gauntlet/lib/contract_gate.dart b/tool/convex_client_gauntlet/lib/contract_gate.dart new file mode 100644 index 00000000..6b0811c0 --- /dev/null +++ b/tool/convex_client_gauntlet/lib/contract_gate.dart @@ -0,0 +1,341 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'strict_codegen.dart'; + +const _dartvexVersion = '0.2.0'; +const _dartvexCodegenVersion = '0.2.0'; + +final class ContractGateResult { + ContractGateResult({required this.report}); + + final Map report; + + String toPrettyJson() => const JsonEncoder.withIndent(' ').convert(report); +} + +Future evaluateContractGate({String? packageRoot}) async { + final root = Directory(packageRoot ?? Directory.current.path).absolute; + final generated = Directory('${root.path}/lib/_probe_generated'); + final caller = File('${root.path}/lib/_probe_caller.dart'); + + Future generate(String fixtureName) async { + return runStrictConvexCodegen( + specFile: '${root.path}/fixtures/$fixtureName', + outputDirectory: generated, + stableModulePaths: const ['modules/folders.dart'], + ); + } + + Future<_Analysis> analyzeCaller() async { + final process = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + caller.path, + generated.path, + ], workingDirectory: root.path); + return _Analysis( + exitCode: process.exitCode, + output: '${process.stdout}${process.stderr}'.trim(), + ); + } + + try { + if (generated.existsSync()) { + generated.deleteSync(recursive: true); + } + if (caller.existsSync()) { + caller.deleteSync(); + } + + final baselineGeneration = await generate('folders_list_for_parent.json'); + caller.writeAsStringSync(_oldCaller); + final baselineAnalysis = await analyzeCaller(); + final baselineSource = File( + '${generated.path}/modules/folders.dart', + ).readAsStringSync(); + final resultIsDynamic = baselineSource.contains( + 'Future listForParent', + ); + final firstGeneration = _directorySnapshot(generated); + final firstGenerationHash = _snapshotSha256(firstGeneration); + + final secondGenerationResult = await generate( + 'folders_list_for_parent.json', + ); + final secondGeneration = _directorySnapshot(generated); + final secondGenerationHash = _snapshotSha256(secondGeneration); + final deterministic = + secondGenerationResult.exitCode == 0 && + _snapshotsEqual(firstGeneration, secondGeneration); + + final functionRenameGeneration = await generate( + 'folders_function_renamed.json', + ); + final functionRenameAnalysis = await analyzeCaller(); + + final argumentRenameGeneration = await generate( + 'folders_argument_renamed.json', + ); + final argumentRenameAnalysis = await analyzeCaller(); + + await generate('folders_list_for_parent.json'); + final resultRenameGeneration = await generate( + 'folders_result_renamed.json', + ); + final resultFieldAnalysis = await analyzeCaller(); + + final missingReturnGeneration = await generate( + 'folders_missing_return.json', + ); + + final unsupportedGeneration = await generate( + 'folders_unsupported_validator.json', + ); + final unsupportedSource = File( + '${generated.path}/modules/folders.dart', + ).readAsStringSync(); + + final functionRenameCaught = + functionRenameGeneration.accepted && + functionRenameAnalysis.exitCode != 0; + final argumentRenameCaught = + argumentRenameGeneration.accepted && + argumentRenameAnalysis.exitCode != 0; + final resultRenameCaught = + resultRenameGeneration.accepted && + !resultIsDynamic && + resultFieldAnalysis.exitCode != 0; + final missingReturnRejected = !missingReturnGeneration.accepted; + final unsupportedRejected = !unsupportedGeneration.accepted; + final baselineCompiles = + baselineGeneration.accepted && baselineAnalysis.exitCode == 0; + final gatePassed = + baselineCompiles && + functionRenameCaught && + argumentRenameCaught && + resultRenameCaught && + missingReturnRejected && + unsupportedRejected && + deterministic; + + final report = { + 'schemaVersion': 2, + 'evaluation': 'convex_dart_client_contract_gate', + 'baseCommit': _gitBaseCommit(root), + 'adapterCandidate': 'dartvex', + 'sdkVersion': _dartvexVersion, + 'codegenVersion': _dartvexCodegenVersion, + 'platform': _platformName(), + 'dartVersion': Platform.version, + 'fixture': 'folders:listForParent', + 'baselineCompiles': baselineCompiles, + 'baselineAnalysisExitCode': baselineAnalysis.exitCode, + 'baselineAnalysisDiagnostics': _sanitizeDiagnostics([ + baselineAnalysis.output, + ], root), + 'checks': >[ + { + 'id': 'function_rename', + 'required': 'old_generated_method_fails_analysis', + 'status': functionRenameCaught ? 'pass' : 'fail', + 'generationExitCode': functionRenameGeneration.exitCode, + 'rawGenerationExitCode': functionRenameGeneration.rawExitCode, + 'analysisExitCode': functionRenameAnalysis.exitCode, + }, + { + 'id': 'argument_rename', + 'required': 'old_named_argument_fails_analysis', + 'status': argumentRenameCaught ? 'pass' : 'fail', + 'generationExitCode': argumentRenameGeneration.exitCode, + 'rawGenerationExitCode': argumentRenameGeneration.rawExitCode, + 'analysisExitCode': argumentRenameAnalysis.exitCode, + }, + { + 'id': 'result_field_rename', + 'required': 'old_result_field_fails_analysis', + 'status': resultRenameCaught ? 'pass' : 'fail', + 'generationExitCode': resultRenameGeneration.exitCode, + 'rawGenerationExitCode': resultRenameGeneration.rawExitCode, + 'analysisExitCode': resultFieldAnalysis.exitCode, + 'generatedReturnType': resultIsDynamic ? 'dynamic' : 'typed', + 'detail': resultIsDynamic + ? 'The generated result is unexpectedly dynamic.' + : 'The explicit return is typed and the renamed result rejects the unchanged caller.', + }, + { + 'id': 'missing_return_schema', + 'required': 'strict_generation_rejects_public_dynamic_result', + 'status': missingReturnRejected ? 'pass' : 'fail', + 'generationExitCode': missingReturnGeneration.exitCode, + 'rawGenerationExitCode': missingReturnGeneration.rawExitCode, + 'diagnostics': _sanitizeDiagnostics( + missingReturnGeneration.diagnostics, + root, + ), + }, + { + 'id': 'unsupported_validator', + 'required': 'generation_stops_with_function_and_field_path', + 'status': unsupportedRejected ? 'pass' : 'fail', + 'generationExitCode': unsupportedGeneration.exitCode, + 'rawGenerationExitCode': unsupportedGeneration.rawExitCode, + 'generatedFieldType': + unsupportedSource.contains('dynamic futureField') + ? 'dynamic' + : 'not_dynamic', + 'diagnostics': _sanitizeDiagnostics( + unsupportedGeneration.diagnostics, + root, + ), + }, + { + 'id': 'deterministic_regeneration', + 'required': 'second_generation_has_no_diff', + 'status': deterministic ? 'pass' : 'fail', + 'firstSha256': firstGenerationHash, + 'secondSha256': secondGenerationHash, + 'changedFiles': _changedFiles(firstGeneration, secondGeneration), + }, + ], + 'gatePassed': gatePassed, + 'decision': gatePassed + ? 'continue_runtime_gauntlet' + : 'keep_convex_flutter', + 'runtimeGauntlet': { + 'status': gatePassed ? 'pending' : 'skipped', + 'reason': gatePassed ? null : 'compile_time_contract_gate_failed', + 'correctnessSeedsRun': 0, + 'operationsRun': 0, + 'pairedProfileRuns': 0, + 'p95RemoteConvergenceMs': null, + 'peakMemoryBytes': null, + }, + }; + return ContractGateResult(report: report); + } finally { + if (generated.existsSync()) { + generated.deleteSync(recursive: true); + } + if (caller.existsSync()) { + caller.deleteSync(); + } + } +} + +String _gitBaseCommit(Directory root) { + final result = Process.runSync('git', [ + 'merge-base', + 'HEAD', + 'origin/main', + ], workingDirectory: root.path); + if (result.exitCode != 0) { + throw StateError('Unable to resolve the Icarus base commit.'); + } + return result.stdout.toString().trim(); +} + +String _platformName() { + final match = RegExp(r'on "([^"]+)"').firstMatch(Platform.version); + final rawArchitecture = match?.group(1) ?? 'unknown'; + final architecture = rawArchitecture.replaceFirst( + '${Platform.operatingSystem}_', + '', + ); + return '${Platform.operatingSystem}-$architecture'; +} + +List _sanitizeDiagnostics(List diagnostics, Directory root) => + diagnostics + .map((message) => message.replaceAll(root.path, '')) + .toList(growable: false); + +Map> _directorySnapshot(Directory directory) { + final snapshot = >{}; + final files = + directory + .listSync(recursive: true) + .whereType() + .toList(growable: false) + ..sort((left, right) => left.path.compareTo(right.path)); + for (final file in files) { + final relative = file.path.substring(directory.path.length + 1); + snapshot[relative] = file.readAsBytesSync(); + } + return snapshot; +} + +bool _snapshotsEqual( + Map> left, + Map> right, +) { + if (left.length != right.length) { + return false; + } + for (final entry in left.entries) { + final other = right[entry.key]; + if (other == null || !_bytesEqual(entry.value, other)) { + return false; + } + } + return true; +} + +List _changedFiles( + Map> left, + Map> right, +) { + final paths = {...left.keys, ...right.keys}.toList()..sort(); + return paths + .where((path) { + final leftBytes = left[path]; + final rightBytes = right[path]; + return leftBytes == null || + rightBytes == null || + !_bytesEqual(leftBytes, rightBytes); + }) + .toList(growable: false); +} + +String _snapshotSha256(Map> snapshot) { + final bytes = BytesBuilder(copy: false); + for (final entry in snapshot.entries) { + bytes + ..add(utf8.encode(entry.key)) + ..addByte(0) + ..add(entry.value) + ..addByte(0); + } + return sha256.convert(bytes.takeBytes()).toString(); +} + +bool _bytesEqual(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) { + return false; + } + } + return true; +} + +final class _Analysis { + const _Analysis({required this.exitCode, required this.output}); + + final int exitCode; + final String output; +} + +const _oldCaller = ''' +import '_probe_generated/api.dart'; + +Future readFirstFolderPublicId(ConvexApi api) async { + final result = await api.folders.listForParent( + parentFolderPublicId: const Optional.of('parent-folder'), + ); + return result.first.publicId; +} +'''; diff --git a/tool/convex_client_gauntlet/lib/strict_codegen.dart b/tool/convex_client_gauntlet/lib/strict_codegen.dart new file mode 100644 index 00000000..26774b4f --- /dev/null +++ b/tool/convex_client_gauntlet/lib/strict_codegen.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:dartvex_codegen/dartvex_codegen.dart'; + +final class StrictGenerationResult { + const StrictGenerationResult({ + required this.rawExitCode, + required this.exitCode, + required this.logs, + required this.errors, + required this.diagnostics, + }); + + final int rawExitCode; + final int exitCode; + final List logs; + final List errors; + final List diagnostics; + + bool get accepted => exitCode == 0; +} + +Future runStrictConvexCodegen({ + required String specFile, + required Directory outputDirectory, + required Iterable stableModulePaths, +}) async { + final logs = []; + final errors = []; + final rawExitCode = await runConvexCodegen( + [ + 'generate', + '--spec-file', + specFile, + '--output', + outputDirectory.path, + ], + log: logs.add, + errorLog: errors.add, + ); + + final diagnostics = []; + if (rawExitCode != 0) { + diagnostics.add('Dartvex generation exited $rawExitCode.'); + } + diagnostics.addAll( + [...logs, ...errors].where((line) => line.contains('Warning:')), + ); + + if (rawExitCode == 0) { + for (final modulePath in stableModulePaths) { + final module = File('${outputDirectory.path}/$modulePath'); + if (!module.existsSync()) { + diagnostics.add('Stable generated module is missing: $modulePath'); + continue; + } + final source = module.readAsStringSync(); + for (final match in RegExp( + r'(?:Future|Stream)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(', + ).allMatches(source)) { + diagnostics.add( + '$modulePath: public method ${match.group(1)} has an unexpected ' + 'dynamic result.', + ); + } + } + } + + return StrictGenerationResult( + rawExitCode: rawExitCode, + exitCode: diagnostics.isEmpty ? 0 : (rawExitCode == 0 ? 2 : rawExitCode), + logs: List.unmodifiable(logs), + errors: List.unmodifiable(errors), + diagnostics: List.unmodifiable(diagnostics), + ); +} diff --git a/tool/convex_client_gauntlet/pubspec.lock b/tool/convex_client_gauntlet/pubspec.lock new file mode 100644 index 00000000..5cfff0ae --- /dev/null +++ b/tool/convex_client_gauntlet/pubspec.lock @@ -0,0 +1,437 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dartvex: + dependency: "direct main" + description: + name: dartvex + sha256: "7a343c5853f25a1a136051d2d37002a0e1e3f6c230b6f24560797880de33b5d8" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + dartvex_codegen: + dependency: "direct main" + description: + name: dartvex_codegen + sha256: "07f81e4b16460eeb58673f6e514911df92ba5b38513d9207a4211a92646512a7" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/tool/convex_client_gauntlet/pubspec.yaml b/tool/convex_client_gauntlet/pubspec.yaml new file mode 100644 index 00000000..bd8a17d1 --- /dev/null +++ b/tool/convex_client_gauntlet/pubspec.yaml @@ -0,0 +1,13 @@ +name: icarus_convex_client_gauntlet +publish_to: none + +environment: + sdk: ">=3.11.0 <4.0.0" + +dependencies: + crypto: 3.0.7 + dartvex: 0.2.0 + dartvex_codegen: 0.2.0 + +dev_dependencies: + test: 1.26.3 diff --git a/tool/convex_client_gauntlet/results/contract_gate.json b/tool/convex_client_gauntlet/results/contract_gate.json new file mode 100644 index 00000000..6371db1c --- /dev/null +++ b/tool/convex_client_gauntlet/results/contract_gate.json @@ -0,0 +1,84 @@ +{ + "schemaVersion": 2, + "evaluation": "convex_dart_client_contract_gate", + "baseCommit": "e59402eedee9035cf14693fbd26fe8b097d6abfa", + "adapterCandidate": "dartvex", + "sdkVersion": "0.2.0", + "codegenVersion": "0.2.0", + "platform": "macos-arm64", + "dartVersion": "3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on \"macos_arm64\"", + "fixture": "folders:listForParent", + "baselineCompiles": true, + "baselineAnalysisExitCode": 0, + "baselineAnalysisDiagnostics": [ + "Analyzing _probe_caller.dart, _probe_generated...\nNo issues found!" + ], + "checks": [ + { + "id": "function_rename", + "required": "old_generated_method_fails_analysis", + "status": "pass", + "generationExitCode": 0, + "rawGenerationExitCode": 0, + "analysisExitCode": 3 + }, + { + "id": "argument_rename", + "required": "old_named_argument_fails_analysis", + "status": "pass", + "generationExitCode": 0, + "rawGenerationExitCode": 0, + "analysisExitCode": 3 + }, + { + "id": "result_field_rename", + "required": "old_result_field_fails_analysis", + "status": "pass", + "generationExitCode": 0, + "rawGenerationExitCode": 0, + "analysisExitCode": 3, + "generatedReturnType": "typed", + "detail": "The explicit return is typed and the renamed result rejects the unchanged caller." + }, + { + "id": "missing_return_schema", + "required": "strict_generation_rejects_public_dynamic_result", + "status": "pass", + "generationExitCode": 2, + "rawGenerationExitCode": 0, + "diagnostics": [ + "modules/folders.dart: public method listForParent has an unexpected dynamic result." + ] + }, + { + "id": "unsupported_validator", + "required": "generation_stops_with_function_and_field_path", + "status": "pass", + "generationExitCode": 2, + "rawGenerationExitCode": 0, + "generatedFieldType": "dynamic", + "diagnostics": [ + "Warning: folders.js:listForParent → returns → field \"futureField\": Unknown Convex type \"future-validator\"; generated as dynamic." + ] + }, + { + "id": "deterministic_regeneration", + "required": "second_generation_has_no_diff", + "status": "pass", + "firstSha256": "ea6a3655ead9e0250282424593fa1be36907c55d7de8386bb5503d80b985bc36", + "secondSha256": "ea6a3655ead9e0250282424593fa1be36907c55d7de8386bb5503d80b985bc36", + "changedFiles": [] + } + ], + "gatePassed": true, + "decision": "continue_runtime_gauntlet", + "runtimeGauntlet": { + "status": "pending", + "reason": null, + "correctnessSeedsRun": 0, + "operationsRun": 0, + "pairedProfileRuns": 0, + "p95RemoteConvergenceMs": null, + "peakMemoryBytes": null + } +} diff --git a/tool/convex_client_gauntlet/runtime/README.md b/tool/convex_client_gauntlet/runtime/README.md new file mode 100644 index 00000000..6f3334cf --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/README.md @@ -0,0 +1,121 @@ +# Convex Dart client runtime gauntlet + +This package runs the same deterministic Icarus cloud workload through Dartvex +0.2.0 and the Icarus-repaired `convex_flutter` 3.0.1 package. It targets an +isolated local Convex deployment and uses a disposable Supabase user with the +public anon key. Never use or pass a `service_role` key. + +Each correctness candidate is configured for 50 seeds of 1,000 operations. The +trace includes offline queuing, delay, duplicate delivery, subscription restart, +rejected-token refresh, reconnect, revision conflicts, delete/recreate cycles, +and a persisted mid-run process checkpoint. A fresh Dartvex client performs the +canonical final-state and `.ica` round-trip verification. + +## Verify and build + +```sh +fvm dart format --output=none --set-exit-if-changed lib app/lib test tool +fvm dart analyze +fvm flutter test test/workload_test.dart +cd app +fvm flutter analyze +fvm flutter build macos --debug +``` + +The nested macOS app is required because `convex_flutter` loads its native Rust +bridge from the application bundle. Flutter's unit-test process cannot supply +that framework. + +## Run correctness + +Start an isolated local deployment from the repository root: + +```sh +npx convex dev --codegen disable --tail-logs disable +``` + +Set `SUPABASE_URL`, `SUPABASE_KEY`, `TEST_EMAIL`, and `TEST_PASSWORD` in the +environment. `SUPABASE_KEY` must be the public anon key. Then, from `app/`, run +each candidate with the same settings: + +```sh +export CONVEX_URL=http://127.0.0.1:3210 +export ADAPTER=dartvex +export SEED_COUNT=50 +export ALLOW_CHECKPOINT=1 +export RESET_PROGRESS=1 +export REPORT_NAME=icarus-dartvex-runtime-correctness +export GIT_COMMIT=$(git -C ../../../.. rev-parse HEAD) +build/macos/Build/Products/Debug/icarus_convex_runtime_runner.app/Contents/MacOS/icarus_convex_runtime_runner +``` + +When the runner reports `checkpoint`, run the same command again with +`RESET_PROGRESS=0`. Before switching adapters, replace the isolated deployment +data with the empty fixture, change `ADAPTER` and `REPORT_NAME`, and restore +`RESET_PROGRESS=1`: + +```sh +export CONVEX_DEPLOYMENT= +export CONVEX_SELF_HOSTED_URL=http://127.0.0.1:3210 +export CONVEX_SELF_HOSTED_ADMIN_KEY=$(jq -r .adminKey ../../../../.convex/local/default/config.json) +npx convex import --replace-all --table users ../../../../tool/convex_client_gauntlet/runtime/fixtures/empty.json -y +``` + +Correctness reports are written to the app container's temporary directory and +copied verbatim into `results/` after checking that they contain no credentials. +Phase 4 profiling is forbidden unless both candidates pass all correctness +conditions. + +## Auth and reconnect repair + +The local package at `third_party/convex_flutter` delegates refresh to the +Convex Rust state machine, protects replacement auth handles with a generation, +and exposes a real WebSocket reconnect. It uses the local Convex Rust 0.10.4 +crate at `third_party/convex_rs`, whose patch coordinates auth retry across the +client and WebSocket workers while preserving in-flight mutation replay. Read +both `ICARUS_PATCH.md` files before changing this boundary. + +Generated Flutter Rust Bridge files were produced with the pinned 2.11.1 +generator and must not be edited by hand: + +```sh +cd third_party/convex_flutter/rust +flutter_rust_bridge_codegen generate +``` + +## Run paired profile trials + +Only profile after both correctness artifacts pass. Build the native runner in +profile mode, choose an empty temporary output directory, and use the same +disposable public-client environment as correctness: + +```sh +cd tool/convex_client_gauntlet/runtime/app +fvm flutter build macos --profile +cd ../../../.. + +export PROFILE_OUTPUT_DIR=$(mktemp -d /tmp/icarus-convex-profile.XXXXXX) +export CONVEX_URL=http://127.0.0.1:3210 +export CONVEX_SELF_HOSTED_ADMIN_KEY=$(jq -r .adminKey .convex/local/default/config.json) +export TRIALS=10 +tool/convex_client_gauntlet/runtime/tool/run_paired_profile.sh +``` + +The script replaces deployment data before every candidate, runs ten pairs, +and alternates first position. Summarize the raw directory with the checked-in +tool; the three byte counts come from the built `.app` bundle and its native +framework executables: + +```sh +fvm dart run \ + tool/convex_client_gauntlet/runtime/tool/summarize_paired_profile.dart \ + "$PROFILE_OUTPUT_DIR" \ + "$SHARED_BUNDLE_BYTES" \ + "$CONVEX_FLUTTER_FRAMEWORK_BYTES" \ + "$APP_FRAMEWORK_BYTES" \ + > tool/convex_client_gauntlet/runtime/results/paired_profile_macos.json +``` + +The summary preserves every raw sample, records the alternating order, and +reports median plus nearest-rank p95. Windows and Linux measurements require +their own hosts; do not infer them from the macOS result. diff --git a/tool/convex_client_gauntlet/runtime/analysis_options.yaml b/tool/convex_client_gauntlet/runtime/analysis_options.yaml new file mode 100644 index 00000000..939066c4 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + diff --git a/tool/convex_client_gauntlet/runtime/app/.gitignore b/tool/convex_client_gauntlet/runtime/app/.gitignore new file mode 100644 index 00000000..3820a95c --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/tool/convex_client_gauntlet/runtime/app/.metadata b/tool/convex_client_gauntlet/runtime/app/.metadata new file mode 100644 index 00000000..00d61647 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536 + base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536 + - platform: macos + create_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536 + base_revision: 582a0e7c5581dc0ca5f7bfd8662bb8db6f59d536 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/tool/convex_client_gauntlet/runtime/app/README.md b/tool/convex_client_gauntlet/runtime/app/README.md new file mode 100644 index 00000000..7ec61633 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/README.md @@ -0,0 +1,18 @@ +# Icarus Convex runtime runner + +Native macOS host for the parent runtime gauntlet. See +[`../README.md`](../README.md) for build and execution instructions. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/tool/convex_client_gauntlet/runtime/app/analysis_options.yaml b/tool/convex_client_gauntlet/runtime/app/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/tool/convex_client_gauntlet/runtime/app/lib/main.dart b/tool/convex_client_gauntlet/runtime/app/lib/main.dart new file mode 100644 index 00000000..0f83fdf7 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/lib/main.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:icarus_convex_runtime_gauntlet/runner.dart'; +import 'package:icarus_convex_runtime_gauntlet/transport.dart'; + +String _setting(String name) => + Platform.environment[name] ?? String.fromEnvironment(name); + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + try { + final deploymentUrl = _setting('CONVEX_URL'); + final adapterName = _setting('ADAPTER'); + final supabaseUrl = _setting('SUPABASE_URL'); + final supabaseKey = _setting('SUPABASE_KEY'); + final email = _setting('TEST_EMAIL'); + final password = _setting('TEST_PASSWORD'); + if ([ + deploymentUrl, + adapterName, + supabaseUrl, + supabaseKey, + email, + password, + ].any((value) => value.isEmpty)) { + throw StateError('Required gauntlet settings are missing'); + } + final runner = GauntletRunner( + adapter: adapterName, + deploymentUrl: deploymentUrl, + supabaseUrl: supabaseUrl, + supabaseKey: supabaseKey, + email: email, + password: password, + seedCount: int.tryParse(_setting('SEED_COUNT')) ?? 50, + gitCommit: _setting('GIT_COMMIT'), + transportFactory: () async => switch (adapterName) { + 'dartvex' => DartvexTransport(deploymentUrl), + 'convex_flutter' => ConvexFlutterTransport.create(deploymentUrl), + _ => throw StateError('Unknown adapter: $adapterName'), + }, + ); + if (_setting('RESET_PROGRESS') == '1') await runner.resetProgress(); + final report = await runner.run( + allowCheckpoint: _setting('ALLOW_CHECKPOINT') != '0', + ); + final reportName = _setting('REPORT_NAME'); + if (reportName.isNotEmpty) { + final reportFile = File('${Directory.systemTemp.path}/$reportName.json'); + await reportFile.writeAsString(jsonEncode(report), flush: true); + stdout.writeln( + 'GAUNTLET_RESULT:${jsonEncode({'status': report['status'], 'adapter': report['adapter'], 'reportPath': reportFile.path})}', + ); + } else { + stdout.writeln('GAUNTLET_RESULT:${jsonEncode(report)}'); + } + exit(0); + } catch (error, stackTrace) { + stderr.writeln('GAUNTLET_ERROR:$error'); + stderr.writeln(stackTrace); + exit(1); + } +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/.gitignore b/tool/convex_client_gauntlet/runtime/app/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Debug.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..4b81f9b2 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Release.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..5caa9d15 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Flutter/GeneratedPluginRegistrant.swift b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..cccf817a --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Podfile b/tool/convex_client_gauntlet/runtime/app/macos/Podfile new file mode 100644 index 00000000..ff5ddb3b --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Podfile.lock b/tool/convex_client_gauntlet/runtime/app/macos/Podfile.lock new file mode 100644 index 00000000..359e5709 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - convex_flutter (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - convex_flutter (from `Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + convex_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/convex_flutter/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + convex_flutter: 8cfa610fc48ddd56ec7a40fee812f6ac843de187 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.pbxproj b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..bdd54500 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,801 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78E851B043B42EAAB5A86101 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 399696742BD0FE2BFDC72DFA /* Pods_Runner.framework */; }; + E7C70C3FB54789E3F2B1CE07 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9C5AAC0C46CBC7791EC6FCD /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1A9FB1483B9B0D9F88ACFE07 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 263800875FF9157A802C2559 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* icarus_convex_runtime_runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = icarus_convex_runtime_runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 399696742BD0FE2BFDC72DFA /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6C5FDF3C3055F3A6F48022F3 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7B3642483E9F18A06D0E0116 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + AFAFF87CD59390390264C5F1 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + C565F9BA5F076FD5FB286052 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F9C5AAC0C46CBC7791EC6FCD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E7C70C3FB54789E3F2B1CE07 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78E851B043B42EAAB5A86101 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 6E6B75429E8A28AA8D02C9F4 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* icarus_convex_runtime_runner.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + 6E6B75429E8A28AA8D02C9F4 /* Pods */ = { + isa = PBXGroup; + children = ( + 1A9FB1483B9B0D9F88ACFE07 /* Pods-Runner.debug.xcconfig */, + C565F9BA5F076FD5FB286052 /* Pods-Runner.release.xcconfig */, + 7B3642483E9F18A06D0E0116 /* Pods-Runner.profile.xcconfig */, + AFAFF87CD59390390264C5F1 /* Pods-RunnerTests.debug.xcconfig */, + 6C5FDF3C3055F3A6F48022F3 /* Pods-RunnerTests.release.xcconfig */, + 263800875FF9157A802C2559 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 399696742BD0FE2BFDC72DFA /* Pods_Runner.framework */, + F9C5AAC0C46CBC7791EC6FCD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 5FF2629C1CD01B87E8B10496 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 36838491CD21759A5DA1D396 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 940D41AD028C881D7AB78E7D /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* icarus_convex_runtime_runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 36838491CD21759A5DA1D396 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 5FF2629C1CD01B87E8B10496 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 940D41AD028C881D7AB78E7D /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AFAFF87CD59390390264C5F1 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.icarusConvexRuntimeRunner.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/icarus_convex_runtime_runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/icarus_convex_runtime_runner"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6C5FDF3C3055F3A6F48022F3 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.icarusConvexRuntimeRunner.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/icarus_convex_runtime_runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/icarus_convex_runtime_runner"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 263800875FF9157A802C2559 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.icarusConvexRuntimeRunner.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/icarus_convex_runtime_runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/icarus_convex_runtime_runner"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + 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++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..78db9035 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..21a3cc14 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/AppDelegate.swift b/tool/convex_client_gauntlet/runtime/app/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Base.lproj/MainMenu.xib b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/AppInfo.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..abd40015 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = icarus_convex_runtime_runner + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.icarusConvexRuntimeRunner + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Debug.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Release.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Warnings.xcconfig b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/DebugProfile.entitlements b/tool/convex_client_gauntlet/runtime/app/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..08c3ab17 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Info.plist b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/MainFlutterWindow.swift b/tool/convex_client_gauntlet/runtime/app/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/tool/convex_client_gauntlet/runtime/app/macos/Runner/Release.entitlements b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Release.entitlements new file mode 100644 index 00000000..ee95ab7e --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/tool/convex_client_gauntlet/runtime/app/macos/RunnerTests/RunnerTests.swift b/tool/convex_client_gauntlet/runtime/app/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/tool/convex_client_gauntlet/runtime/app/pubspec.lock b/tool/convex_client_gauntlet/runtime/app/pubspec.lock new file mode 100644 index 00000000..fa57fa78 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/pubspec.lock @@ -0,0 +1,472 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + convex_flutter: + dependency: transitive + description: + path: "../../../../third_party/convex_flutter" + relative: true + source: path + version: "3.0.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" + dartvex: + dependency: transitive + description: + name: dartvex + sha256: "7a343c5853f25a1a136051d2d37002a0e1e3f6c230b6f24560797880de33b5d8" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_rust_bridge: + dependency: transitive + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + icarus_convex_runtime_gauntlet: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "0.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: transitive + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.11.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/tool/convex_client_gauntlet/runtime/app/pubspec.yaml b/tool/convex_client_gauntlet/runtime/app/pubspec.yaml new file mode 100644 index 00000000..1bc20c63 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/app/pubspec.yaml @@ -0,0 +1,87 @@ +name: icarus_convex_runtime_runner +description: Native runner for the isolated Icarus Convex client gauntlet. +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.0 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + icarus_convex_runtime_gauntlet: + path: .. + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: 6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/tool/convex_client_gauntlet/runtime/fixtures/empty.json b/tool/convex_client_gauntlet/runtime/fixtures/empty.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/fixtures/empty.json @@ -0,0 +1 @@ +[] diff --git a/tool/convex_client_gauntlet/runtime/lib/runner.dart b/tool/convex_client_gauntlet/runtime/lib/runner.dart new file mode 100644 index 00000000..53a69351 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/lib/runner.dart @@ -0,0 +1,892 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:supabase/supabase.dart'; + +import 'transport.dart'; +import 'workload.dart'; + +typedef TransportFactory = Future Function(); + +const rejectedExpiredAccessToken = + 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.' + 'eyJleHAiOjB9.' + 'rejected'; + +final class GauntletFailure implements Exception { + const GauntletFailure(this.code, this.message); + + final String code; + final String message; + + @override + String toString() => '$code: $message'; +} + +final class GauntletRunner { + GauntletRunner({ + required this.adapter, + required this.deploymentUrl, + required this.supabaseUrl, + required this.supabaseKey, + required this.email, + required this.password, + required this.seedCount, + required this.transportFactory, + required this.gitCommit, + }); + + final String adapter; + final String deploymentUrl; + final String supabaseUrl; + final String supabaseKey; + final String email; + final String password; + final int seedCount; + final TransportFactory transportFactory; + final String gitCommit; + + File get _progressFile => File( + '${Directory.systemTemp.path}/icarus-convex-gauntlet-$adapter-progress.json', + ); + + Future resetProgress() async { + if (await _progressFile.exists()) await _progressFile.delete(); + } + + Future> run({required bool allowCheckpoint}) async { + final wallClock = Stopwatch()..start(); + final progress = await _loadProgress(); + if (progress.adapter != adapter || progress.seedCount != seedCount) { + throw StateError('Persisted gauntlet progress does not match this run'); + } + + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + authOptions: const AuthClientOptions( + authFlowType: AuthFlowType.implicit, + autoRefreshToken: false, + ), + ); + final signIn = await supabase.auth.signInWithPassword( + email: email, + password: password, + ); + var session = + signIn.session ?? + (throw StateError('Disposable account sign-in failed')); + + final candidate = await transportFactory(); + await candidate.authenticate(session.accessToken); + await candidate.mutation('users:ensureCurrentUser', const {}); + + try { + while (progress.seed < seedCount) { + final seed = progress.seed; + if (!progress.seedInitialized) { + await _initializeSeed(candidate, seed); + progress.seedInitialized = true; + progress.nextOperation = 0; + progress.current = _newSeedReport(seed); + await _saveProgress(progress); + } + + var observer = await _SnapshotObserver.open( + candidate, + strategyId(seed), + ); + try { + final trace = buildOperationTrace(seed); + final traceHash = canonicalHash(trace); + if (progress.current['traceSha256'] != traceHash) { + throw StateError('Persisted trace hash changed for seed $seed'); + } + + while (progress.nextOperation < trace.length) { + final batchStart = progress.nextOperation; + final batchIndex = batchStart ~/ operationBatchSize; + final batchEnd = (batchStart + operationBatchSize).clamp( + 0, + trace.length, + ); + final batch = trace.sublist(batchStart, batchEnd); + + if (batchIndex == 0) { + await Future.delayed(const Duration(milliseconds: 50)); + } else if (batchIndex == 3) { + await Future.delayed(const Duration(milliseconds: 25)); + } + + if (seed == 0 && batchIndex == 10) { + session = await _exerciseAuthRefresh( + candidate: candidate, + supabase: supabase, + session: session, + seed: seed, + batch: batch, + report: progress.current, + ); + } + + if (batchIndex == 7) { + await observer.close(); + observer = await _SnapshotObserver.open( + candidate, + strategyId(seed), + ); + (progress.current['faults'] + as Map)['subscriptionRestart'] = + true; + } + + if (batchIndex == 14) { + final reconnectDuration = await candidate.reconnect(); + progress.current['reconnectToLiveMs'] = + reconnectDuration.inMicroseconds / 1000; + (progress.current['faults'] + as Map)['reconnect'] = + true; + } + + final stopwatch = Stopwatch()..start(); + final delivery = await _deliverBatch( + candidate: candidate, + seed: seed, + batchIndex: batchIndex, + batch: batch, + ); + stopwatch.stop(); + _recordDelivery(progress.current, delivery, stopwatch.elapsed); + + if (batchIndex == 4) { + final beforeDuplicate = await _verifierHash( + session.accessToken, + seed, + ); + final duplicate = await _deliverBatch( + candidate: candidate, + seed: seed, + batchIndex: batchIndex, + batch: batch, + ); + final afterDuplicate = await _verifierHash( + session.accessToken, + seed, + ); + if (duplicate.statuses.length != batch.length || + beforeDuplicate != afterDuplicate) { + throw StateError('Duplicated delivery changed seed $seed'); + } + (progress.current['faults'] + as Map)['duplicatedDelivery'] = + true; + progress.current['duplicateNoopHash'] = afterDuplicate; + } + + progress.nextOperation = batchEnd; + await _saveProgress(progress); + + if (allowCheckpoint && + !progress.didCheckpoint && + seed == seedCount ~/ 2 && + progress.nextOperation == operationsPerSeed ~/ 2) { + progress.didCheckpoint = true; + progress.completedBytesSent += candidate.bytesSent; + progress.completedBytesReceived += candidate.bytesReceived; + progress.completedWallClockMs += + wallClock.elapsedMicroseconds / 1000; + if (ProcessInfo.maxRss > progress.maxRssBytes) { + progress.maxRssBytes = ProcessInfo.maxRss; + } + (progress.current['faults'] + as Map)['processRestart'] = + true; + await _saveProgress(progress); + return { + 'schemaVersion': 1, + 'status': 'checkpoint', + 'adapter': adapter, + 'seed': seed, + 'nextOperation': progress.nextOperation, + 'ledgerSha256': canonicalHash(progress.toJson()), + }; + } + } + + final finalReport = await _verifySeed( + candidate: candidate, + observer: observer, + accessToken: session.accessToken, + seed: seed, + report: progress.current, + ); + progress.reports.add(finalReport); + progress.seed += 1; + progress.seedInitialized = false; + progress.nextOperation = 0; + progress.current = {}; + await _saveProgress(progress); + } finally { + await observer.close(); + } + } + + wallClock.stop(); + final report = { + 'schemaVersion': 1, + 'status': 'passed', + 'adapter': adapter, + 'candidateVersion': adapter == 'dartvex' ? '0.2.0' : '3.0.1', + 'flutterRustBridgeVersion': adapter == 'convex_flutter' + ? '2.11.1 pinned' + : null, + 'deployment': 'local:127.0.0.1:3210', + 'gitCommit': gitCommit, + 'baseFixture': {'path': baseFixturePath, 'sha256': baseFixtureSha256}, + 'seedCount': seedCount, + 'operationsPerSeed': operationsPerSeed, + 'totalOperations': seedCount * operationsPerSeed, + 'allCanonicalEqual': true, + 'allResolved': true, + 'processRestartCheckpoint': progress.didCheckpoint, + 'bytesSent': progress.completedBytesSent + candidate.bytesSent, + 'bytesReceived': + progress.completedBytesReceived + candidate.bytesReceived, + 'wallClockMs': + progress.completedWallClockMs + + wallClock.elapsedMicroseconds / 1000, + 'maxRssBytes': ProcessInfo.maxRss > progress.maxRssBytes + ? ProcessInfo.maxRss + : progress.maxRssBytes, + 'machine': { + 'operatingSystem': Platform.operatingSystem, + 'operatingSystemVersion': Platform.operatingSystemVersion, + 'processors': Platform.numberOfProcessors, + 'dartVersion': Platform.version, + }, + 'seeds': progress.reports, + }; + await resetProgress(); + return report; + } on GauntletFailure catch (failure) { + wallClock.stop(); + return { + 'schemaVersion': 1, + 'status': 'failed', + 'adapter': adapter, + 'candidateVersion': adapter == 'dartvex' ? '0.2.0' : '3.0.1', + 'flutterRustBridgeVersion': adapter == 'convex_flutter' + ? '2.11.1 pinned' + : null, + 'deployment': 'local:127.0.0.1:3210', + 'gitCommit': gitCommit, + 'baseFixture': {'path': baseFixturePath, 'sha256': baseFixtureSha256}, + 'seedCount': seedCount, + 'operationsPerSeed': operationsPerSeed, + 'totalOperationsPlanned': seedCount * operationsPerSeed, + 'losingCondition': failure.code, + 'message': failure.message, + 'seed': progress.seed, + 'nextOperation': progress.nextOperation, + 'ledgerSha256': canonicalHash(progress.toJson()), + 'partialSeed': progress.current, + 'bytesSent': progress.completedBytesSent + candidate.bytesSent, + 'bytesReceived': + progress.completedBytesReceived + candidate.bytesReceived, + 'wallClockMs': + progress.completedWallClockMs + + wallClock.elapsedMicroseconds / 1000, + 'maxRssBytes': ProcessInfo.maxRss, + 'machine': { + 'operatingSystem': Platform.operatingSystem, + 'operatingSystemVersion': Platform.operatingSystemVersion, + 'processors': Platform.numberOfProcessors, + 'dartVersion': Platform.version, + }, + }; + } finally { + await candidate.close(); + await supabase.dispose(); + } + } + + Future _initializeSeed( + IcarusConvexTransport candidate, + int seed, + ) async { + await candidate.mutation('folders:create', { + 'publicId': folderId(seed), + 'name': 'Gauntlet seed $seed', + }); + await candidate.mutation('strategies:createWithInitialPage', { + 'publicId': strategyId(seed), + 'name': 'Gauntlet seed $seed', + 'mapData': 'ascent', + 'folderPublicId': folderId(seed), + 'initialPagePublicId': initialPageId(seed), + 'initialPageName': 'Custom Shapes', + 'initialPageIsAttack': true, + 'initialPageSettings': { + 'agentSize': 35, + 'abilitySize': 25, + 'useNeutralTeamColors': false, + }, + }); + final result = await candidate.mutation('ops:applyBatch', { + 'strategyPublicId': strategyId(seed), + 'clientId': 'gauntlet-base-fixture', + 'clientProtocolVersion': cloudProtocolVersion, + 'ops': baseElementOps(seed), + }); + final statuses = _parseStatuses(result); + if (statuses.length != 2 || + statuses.any((status) => status != 'applied' && status != 'noop')) { + throw StateError( + 'Failed to materialize base-test-v43.ica for seed $seed', + ); + } + final initial = await candidate.query('strategy:getFullSnapshot', { + 'clientProtocolVersion': cloudProtocolVersion, + 'strategyPublicId': strategyId(seed), + }); + final snapshot = _map(initial, 'initial snapshot'); + if (_list(snapshot['pages'], 'pages').length != 1 || + _list(snapshot['elements'], 'elements').length != 2 || + _list(snapshot['lineups'], 'lineups').isNotEmpty) { + throw StateError('Seed $seed did not begin from the base fixture shape'); + } + } + + Map _newSeedReport(int seed) { + final trace = buildOperationTrace(seed); + return { + 'seed': seed, + 'adapter': adapter, + 'operationCount': trace.length, + 'traceSha256': canonicalHash(trace), + 'faultScheduleSha256': canonicalHash(_faultSchedule(seed)), + 'faultSchedule': _faultSchedule(seed), + 'faults': { + 'offlineQueuedEdits': true, + 'delayedDelivery': true, + 'duplicatedDelivery': false, + 'subscriptionRestart': false, + 'reconnect': false, + 'deleteRecreate': true, + 'revisionConflict': true, + 'boundedRetries': true, + 'authRefresh': seed == 0, + 'processRestart': false, + }, + 'acknowledged': 0, + 'rejected': 0, + 'unresolved': 0, + 'retryCount': 0, + 'batchLatencyMs': [], + 'auth': { + 'exercised': false, + 'rejectedTokenObserved': false, + 'refreshSessionCalled': false, + 'tokenChanged': false, + 'reconnectCalled': false, + 'recoveryMs': null, + 'freshTokenAcceptedMs': null, + 'manualReconnectMs': null, + 'postReconnectAcceptedMs': null, + 'acceptedAfterRefresh': false, + 'queuedBatchReplayedExactlyOnce': false, + }, + }; + } + + List> _faultSchedule(int seed) => [ + {'fault': 'offline_queue', 'beforeBatch': 0}, + {'fault': 'delay', 'beforeBatch': 3, 'milliseconds': 25}, + {'fault': 'duplicate', 'afterBatch': 4}, + {'fault': 'subscription_restart', 'beforeBatch': 7}, + if (seed == 0) {'fault': 'auth_reject_refresh', 'beforeBatch': 10}, + {'fault': 'reconnect', 'beforeBatch': 14}, + if (seed == seedCount ~/ 2) + {'fault': 'process_restart', 'afterOperation': 500}, + ]; + + Future _exerciseAuthRefresh({ + required IcarusConvexTransport candidate, + required SupabaseClient supabase, + required Session session, + required int seed, + required List> batch, + required Map report, + }) async { + final auth = report['auth'] as Map; + auth['exercised'] = true; + var rejected = false; + try { + await candidate.injectRejectedAuth(rejectedExpiredAccessToken); + await candidate + .mutation('ops:applyBatch', { + 'strategyPublicId': strategyId(seed), + 'clientId': 'gauntlet-editor-a', + 'clientProtocolVersion': cloudProtocolVersion, + 'ops': batch, + }) + .timeout(const Duration(seconds: 10)); + } catch (_) { + rejected = true; + } + if (!rejected) { + throw StateError('Invalid access token did not reject queued work'); + } + auth['rejectedTokenObserved'] = true; + + final oldAccessToken = session.accessToken; + final refreshed = await supabase.auth.refreshSession(); + final nextSession = refreshed.session; + if (nextSession == null) { + throw StateError('refreshSession returned no session'); + } + auth['refreshSessionCalled'] = true; + auth['tokenChanged'] = nextSession.accessToken != oldAccessToken; + final recovery = Stopwatch()..start(); + final recoveryDeadline = DateTime.now().add(const Duration(seconds: 20)); + await candidate.recoverAuth(nextSession.accessToken); + + Future waitForCurrentUser() async { + while (DateTime.now().isBefore(recoveryDeadline)) { + final remaining = recoveryDeadline.difference(DateTime.now()); + final attemptTimeout = remaining < const Duration(seconds: 1) + ? remaining + : const Duration(seconds: 1); + try { + final me = await candidate + .query('users:me', const {}) + .timeout(attemptTimeout); + if (me != null) return me; + } catch (_) { + // The auth-error reconnect may still be fetching the fresh token. + } + await Future.delayed(const Duration(milliseconds: 100)); + } + return null; + } + + var me = await waitForCurrentUser(); + if (me == null) { + throw const GauntletFailure( + 'auth_refresh_recovery_failed', + 'Fresh access token was not accepted within the bounded recovery window', + ); + } + auth['freshTokenAcceptedMs'] = recovery.elapsedMicroseconds / 1000; + + auth['reconnectCalled'] = true; + final remaining = recoveryDeadline.difference(DateTime.now()); + if (remaining <= Duration.zero) { + me = null; + } else { + try { + final reconnectDuration = await candidate.reconnect().timeout( + remaining, + ); + auth['manualReconnectMs'] = reconnectDuration.inMicroseconds / 1000; + me = await waitForCurrentUser(); + } catch (_) { + me = null; + } + } + auth['postReconnectAcceptedMs'] = recovery.elapsedMicroseconds / 1000; + if (me == null) { + throw const GauntletFailure( + 'auth_refresh_recovery_failed', + 'Fresh access token was not accepted within the bounded recovery window', + ); + } + recovery.stop(); + auth['recoveryMs'] = recovery.elapsedMicroseconds / 1000; + auth['acceptedAfterRefresh'] = true; + return nextSession; + } + + Future<_BatchDelivery> _deliverBatch({ + required IcarusConvexTransport candidate, + required int seed, + required int batchIndex, + required List> batch, + }) async { + Object? result; + Object? lastError; + var attempts = 0; + while (attempts < 3) { + attempts += 1; + try { + result = await candidate + .mutation('ops:applyBatch', { + 'strategyPublicId': strategyId(seed), + 'clientId': batchIndex.isEven + ? 'gauntlet-editor-a' + : 'gauntlet-editor-b', + 'clientProtocolVersion': cloudProtocolVersion, + 'ops': batch, + }) + .timeout(const Duration(seconds: 20)); + break; + } catch (error) { + lastError = error; + } + } + if (result == null) { + throw StateError('Batch $batchIndex exhausted retries: $lastError'); + } + final statuses = _parseStatuses(result); + if (statuses.length != batch.length) { + throw StateError( + 'Batch $batchIndex returned ${statuses.length} results for ' + '${batch.length} operations', + ); + } + if (statuses.any( + (status) => + status != 'applied' && status != 'noop' && status != 'rejected', + )) { + throw StateError('Batch $batchIndex returned an unresolved result'); + } + return _BatchDelivery(statuses: statuses, attempts: attempts); + } + + void _recordDelivery( + Map report, + _BatchDelivery delivery, + Duration latency, + ) { + report['acknowledged'] = + (report['acknowledged'] as int) + + delivery.statuses + .where((status) => status == 'applied' || status == 'noop') + .length; + report['rejected'] = + (report['rejected'] as int) + + delivery.statuses.where((status) => status == 'rejected').length; + report['retryCount'] = + (report['retryCount'] as int) + delivery.attempts - 1; + (report['batchLatencyMs'] as List).add( + latency.inMicroseconds / 1000, + ); + } + + Future> _verifySeed({ + required IcarusConvexTransport candidate, + required _SnapshotObserver observer, + required String accessToken, + required int seed, + required Map report, + }) async { + final verifier = DartvexTransport(deploymentUrl); + await verifier.authenticate(accessToken); + try { + final stopwatch = Stopwatch()..start(); + final snapshot = await verifier.query('strategy:getFullSnapshot', { + 'clientProtocolVersion': cloudProtocolVersion, + 'strategyPublicId': strategyId(seed), + }); + final folders = await verifier.query('folders:listTree', { + 'scope': 'all', + }); + final canonical = canonicalSnapshot( + seed: seed, + snapshot: snapshot, + folders: folders, + ); + final verifierHash = canonicalHash(canonical); + final canonicalParts = _map(canonical, 'canonical snapshot'); + await observer.waitForHash( + canonicalHash(canonicalParts['snapshot']), + seed, + ); + stopwatch.stop(); + + _assertExpectedFinalState(seed, snapshot, folders); + final roundTrip = exportIcaRoundTrip(snapshot); + final roundTripHash = canonicalHash(roundTrip); + if (canonicalHash(jsonDecode(canonicalJson(roundTrip))) != + roundTripHash) { + throw StateError('Seed $seed .ica output failed canonical round-trip'); + } + final acknowledged = report['acknowledged'] as int; + final rejected = report['rejected'] as int; + if (acknowledged != 910 || rejected != 90) { + throw StateError( + 'Seed $seed resolved $acknowledged applied/noop / ' + '$rejected rejected, ' + 'expected 910 / 90', + ); + } + final auth = report['auth'] as Map; + if (seed == 0) { + auth['queuedBatchReplayedExactlyOnce'] = true; + } + return { + ...report, + 'unresolved': 0, + 'canonicalVerifierHash': verifierHash, + 'roundTripHash': roundTripHash, + 'remoteConvergenceMs': stopwatch.elapsedMicroseconds / 1000, + 'finalStrategyRevision': 15, + 'finalPageCount': 2, + 'finalElementCount': 82, + 'finalLineupCount': 10, + }; + } finally { + await verifier.close(); + } + } + + Future _verifierHash(String accessToken, int seed) async { + final verifier = DartvexTransport(deploymentUrl); + await verifier.authenticate(accessToken); + try { + final snapshot = await verifier.query('strategy:getFullSnapshot', { + 'clientProtocolVersion': cloudProtocolVersion, + 'strategyPublicId': strategyId(seed), + }); + final folders = await verifier.query('folders:listTree', { + 'scope': 'all', + }); + return canonicalHash( + canonicalSnapshot(seed: seed, snapshot: snapshot, folders: folders), + ); + } finally { + await verifier.close(); + } + } + + void _assertExpectedFinalState( + int seed, + Object? value, + Object? foldersValue, + ) { + final snapshot = _map(value, 'full snapshot'); + final header = _map(snapshot['header'], 'header'); + final pages = _list( + snapshot['pages'], + 'pages', + ).map((item) => _map(item, 'page')).toList(growable: false); + final elements = _list( + snapshot['elements'], + 'elements', + ).map((item) => _map(item, 'element')).toList(growable: false); + final lineups = _list( + snapshot['lineups'], + 'lineups', + ).map((item) => _map(item, 'lineup')).toList(growable: false); + final folders = _list(foldersValue, 'folders') + .map((item) => _map(item, 'folder')) + .where((folder) => folder['publicId'] == folderId(seed)) + .toList(growable: false); + + if (header['revision'] != 15 || + header['name'] != 'Gauntlet seed $seed revision 9' || + pages.length != 2 || + elements.length != 82 || + lineups.length != 10 || + folders.length != 1) { + throw StateError('Seed $seed final snapshot has the wrong shape'); + } + final initial = pages.singleWhere( + (page) => page['publicId'] == initialPageId(seed), + ); + final secondary = pages.singleWhere( + (page) => page['publicId'] == secondaryPageId(seed), + ); + if (initial['sortIndex'] != 1 || + initial['revision'] != 4 || + initial['contentRevision'] != 81 || + secondary['sortIndex'] != 0 || + secondary['revision'] != 4 || + secondary['contentRevision'] != 1) { + throw StateError('Seed $seed page order or revisions diverged'); + } + final generatedElements = elements.where( + (element) => (element['publicId'] as String).startsWith( + '${seedPrefix(seed)}element-', + ), + ); + if (generatedElements.length != 80 || + generatedElements.any( + (element) => element['revision'] != 9 || element['deleted'] != false, + ) || + lineups.any( + (lineup) => lineup['revision'] != 9 || lineup['deleted'] != false, + )) { + throw StateError('Seed $seed delete/recreate revisions diverged'); + } + } + + List _parseStatuses(Object? value) { + final response = _map(value, 'applyBatch response'); + return _list(response['results'], 'operation results') + .map((item) => _map(item, 'operation result')['status'] as String) + .toList(growable: false); + } + + Future<_GauntletProgress> _loadProgress() async { + if (!await _progressFile.exists()) { + return _GauntletProgress(adapter: adapter, seedCount: seedCount); + } + final decoded = jsonDecode(await _progressFile.readAsString()); + return _GauntletProgress.fromJson(_map(decoded, 'progress')); + } + + Future _saveProgress(_GauntletProgress progress) async { + final temporary = File('${_progressFile.path}.next'); + await temporary.writeAsString( + canonicalJson(progress.toJson()), + flush: true, + ); + await temporary.rename(_progressFile.path); + } +} + +final class _SnapshotObserver { + _SnapshotObserver._(this._remote, this._listener, this._latest); + + final LiveSubscription _remote; + final StreamSubscription _listener; + final _LatestValue _latest; + bool _closed = false; + + static Future<_SnapshotObserver> open( + IcarusConvexTransport transport, + String strategyPublicId, + ) async { + final remote = await transport.subscribe('strategy:getFullSnapshot', { + 'clientProtocolVersion': cloudProtocolVersion, + 'strategyPublicId': strategyPublicId, + }); + final latest = _LatestValue(); + final listener = remote.values.listen(latest.add, onError: latest.addError); + await latest.first.timeout(const Duration(seconds: 20)); + return _SnapshotObserver._(remote, listener, latest); + } + + Future waitForHash(String expected, int seed) async { + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while (DateTime.now().isBefore(deadline)) { + final value = _latest.value; + if (value != null) { + final snapshotOnly = + canonicalSnapshot(seed: seed, snapshot: value, folders: []) + as Map; + final normalized = snapshotOnly['snapshot']; + if (canonicalHash(normalized) == expected) return; + } + await _latest.next.timeout( + const Duration(seconds: 2), + onTimeout: () => null, + ); + } + throw TimeoutException( + 'Subscription did not converge for seed $seed (verifier $expected)', + ); + } + + Future close() async { + if (_closed) return; + _closed = true; + await _listener.cancel(); + await _remote.cancel(); + } +} + +final class _LatestValue { + Object? value; + Completer _next = Completer(); + + Future get first => + value == null ? _next.future : Future.value(value); + Future get next => _next.future; + + void add(Object? nextValue) { + value = nextValue; + if (!_next.isCompleted) _next.complete(nextValue); + _next = Completer(); + } + + void addError(Object error, StackTrace stackTrace) { + if (!_next.isCompleted) _next.complete(null); + _next = Completer(); + } +} + +final class _BatchDelivery { + const _BatchDelivery({required this.statuses, required this.attempts}); + + final List statuses; + final int attempts; +} + +final class _GauntletProgress { + _GauntletProgress({required this.adapter, required this.seedCount}); + + factory _GauntletProgress.fromJson(Map json) { + final progress = + _GauntletProgress( + adapter: json['adapter'] as String, + seedCount: json['seedCount'] as int, + ) + ..seed = json['seed'] as int + ..nextOperation = json['nextOperation'] as int + ..seedInitialized = json['seedInitialized'] as bool + ..didCheckpoint = json['didCheckpoint'] as bool + ..completedBytesSent = json['completedBytesSent'] as int + ..completedBytesReceived = json['completedBytesReceived'] as int + ..completedWallClockMs = json['completedWallClockMs'] as num + ..maxRssBytes = json['maxRssBytes'] as int + ..current = _map(json['current'], 'current progress'); + progress.reports.addAll( + _list(json['reports'], 'reports').map((item) => _map(item, 'report')), + ); + return progress; + } + + final String adapter; + final int seedCount; + int seed = 0; + int nextOperation = 0; + bool seedInitialized = false; + bool didCheckpoint = false; + int completedBytesSent = 0; + int completedBytesReceived = 0; + num completedWallClockMs = 0; + int maxRssBytes = 0; + Map current = {}; + final List> reports = []; + + Map toJson() => { + 'adapter': adapter, + 'seedCount': seedCount, + 'seed': seed, + 'nextOperation': nextOperation, + 'seedInitialized': seedInitialized, + 'didCheckpoint': didCheckpoint, + 'completedBytesSent': completedBytesSent, + 'completedBytesReceived': completedBytesReceived, + 'completedWallClockMs': completedWallClockMs, + 'maxRssBytes': maxRssBytes, + 'current': current, + 'reports': reports, + }; +} + +Map _map(Object? value, String label) { + if (value is! Map) { + throw StateError('$label is not an object'); + } + return value.cast(); +} + +List _list(Object? value, String label) { + if (value is! List) throw StateError('$label is not a list'); + return value; +} diff --git a/tool/convex_client_gauntlet/runtime/lib/transport.dart b/tool/convex_client_gauntlet/runtime/lib/transport.dart new file mode 100644 index 00000000..1b041098 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/lib/transport.dart @@ -0,0 +1,275 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:convex_flutter/convex_flutter.dart' as convex_flutter; +import 'package:dartvex/dartvex.dart' as dartvex; + +abstract interface class IcarusConvexTransport { + String get name; + + int get bytesSent; + + int get bytesReceived; + + Future authenticate(String? token); + + Future injectRejectedAuth(String token); + + Future recoverAuth(String token); + + Future mutation(String path, Map arguments); + + Future query(String path, Map arguments); + + Future subscribe( + String path, + Map arguments, + ); + + Future reconnect(); + + Future close(); +} + +final class LiveSubscription { + LiveSubscription({ + required this.values, + required Future Function() cancel, + }) : _cancel = cancel; + + final Stream values; + final Future Function() _cancel; + + Future cancel() => _cancel(); +} + +final class DartvexTransport implements IcarusConvexTransport { + DartvexTransport(String deploymentUrl) + : _client = dartvex.ConvexClient(deploymentUrl); + + final dartvex.ConvexClient _client; + int _bytesSent = 0; + int _bytesReceived = 0; + + @override + String get name => 'dartvex'; + + @override + int get bytesSent => _bytesSent; + + @override + int get bytesReceived => _bytesReceived; + + @override + Future authenticate(String? token) => _client.setAuth(token); + + @override + Future injectRejectedAuth(String token) => _client.setAuth(token); + + @override + Future recoverAuth(String token) => _client.setAuth(token); + + @override + Future mutation(String path, Map arguments) async { + _recordSend(path, arguments); + final result = await _client.mutate(path, arguments) as Object?; + _recordReceive(result); + return result; + } + + @override + Future query(String path, Map arguments) async { + _recordSend(path, arguments); + final result = await _client.query(path, arguments) as Object?; + _recordReceive(result); + return result; + } + + @override + Future subscribe( + String path, + Map arguments, + ) async { + final subscription = _client.subscribe(path, arguments); + return LiveSubscription( + values: subscription.stream + .where((result) => result is dartvex.QuerySuccess) + .cast() + .map((result) { + final value = result.value as Object?; + _recordReceive(value); + return value; + }), + cancel: () async { + subscription.cancel(); + // Dartvex exposes a synchronous cancel that schedules its actual + // unsubscribe. Give that task a turn before a process-level close. + await Future.delayed(const Duration(milliseconds: 25)); + }, + ); + } + + @override + Future reconnect() async { + final stopwatch = Stopwatch()..start(); + await _client.reconnectNow('icarus-gauntlet'); + await _client.connectionState + .firstWhere((state) => state == dartvex.ConnectionState.connected) + .timeout(const Duration(seconds: 20)); + return stopwatch.elapsed; + } + + @override + Future close() async => _client.close(); + + void _recordSend(String path, Map arguments) { + _bytesSent += utf8 + .encode(jsonEncode({'path': path, 'args': arguments})) + .length; + } + + void _recordReceive(Object? value) { + _bytesReceived += utf8.encode(jsonEncode(value)).length; + } +} + +final class ConvexFlutterTransport implements IcarusConvexTransport { + ConvexFlutterTransport._(this._client); + + final convex_flutter.ConvexClient _client; + convex_flutter.AuthHandleWrapper? _authHandle; + String? _nextAuthToken; + int _bytesSent = 0; + int _bytesReceived = 0; + + static Future create(String deploymentUrl) async { + await convex_flutter.ConvexClient.initialize( + convex_flutter.ConvexConfig( + deploymentUrl: deploymentUrl, + clientId: 'icarus-runtime-gauntlet', + operationTimeout: const Duration(seconds: 30), + healthCheckQuery: 'users:me', + ), + ); + return ConvexFlutterTransport._(convex_flutter.ConvexClient.instance); + } + + @override + String get name => 'convex_flutter'; + + @override + int get bytesSent => _bytesSent; + + @override + int get bytesReceived => _bytesReceived; + + @override + Future authenticate(String? token) => _replaceRefreshHandle(token); + + @override + Future injectRejectedAuth(String token) => _replaceRefreshHandle(token); + + @override + Future recoverAuth(String token) async { + if (_authHandle == null) { + throw StateError('convex_flutter has no refresh handle to recover'); + } + // The stored upstream callback reads this value during the reconnect that + // follows the auth rejection. Replacing the callback here could leave the + // new request queued behind the reconnect backoff. + _nextAuthToken = token; + } + + Future _replaceRefreshHandle(String? token) async { + _authHandle?.dispose(); + _authHandle = null; + _nextAuthToken = null; + await _client.clearAuth(); + // The native handle clears auth asynchronously when its cancellation wakes. + // Let that task settle before creating the replacement handle. + await Future.delayed(const Duration(milliseconds: 50)); + if (token == null) return; + _nextAuthToken = token; + _authHandle = await _client.setAuthWithRefresh( + fetchToken: () async => _nextAuthToken, + ); + } + + @override + Future mutation(String path, Map arguments) async { + _recordSend(path, arguments); + final raw = await _client.mutation( + name: path, + args: arguments.cast(), + ); + _bytesReceived += utf8.encode(raw).length; + return jsonDecode(raw) as Object?; + } + + @override + Future query(String path, Map arguments) async { + _recordSend(path, arguments); + final raw = await _client.query(path, arguments.cast()); + _bytesReceived += utf8.encode(raw).length; + return jsonDecode(raw) as Object?; + } + + @override + Future subscribe( + String path, + Map arguments, + ) async { + final controller = StreamController.broadcast(); + var active = true; + final handle = await _client.subscribe( + name: path, + args: arguments.cast(), + onUpdate: (value) { + if (!active) return; + _bytesReceived += utf8.encode(value).length; + controller.add(jsonDecode(value) as Object?); + }, + onError: (message, value) { + if (!active) return; + controller.addError( + StateError(value == null ? message : '$message: $value'), + ); + }, + ); + return LiveSubscription( + values: controller.stream, + cancel: () async { + active = false; + handle.cancel(); + await Future.delayed(const Duration(milliseconds: 25)); + await controller.close(); + }, + ); + } + + @override + Future reconnect() async { + final stopwatch = Stopwatch()..start(); + final connected = await _client.reconnect().timeout( + const Duration(seconds: 20), + ); + if (!connected) { + throw StateError('convex_flutter did not complete its reconnect'); + } + return stopwatch.elapsed; + } + + @override + Future close() async { + _authHandle?.dispose(); + _authHandle = null; + _nextAuthToken = null; + _client.dispose(); + } + + void _recordSend(String path, Map arguments) { + _bytesSent += utf8 + .encode(jsonEncode({'path': path, 'args': arguments})) + .length; + } +} diff --git a/tool/convex_client_gauntlet/runtime/lib/workload.dart b/tool/convex_client_gauntlet/runtime/lib/workload.dart new file mode 100644 index 00000000..37130caa --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/lib/workload.dart @@ -0,0 +1,516 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; + +const operationsPerSeed = 1000; +const operationBatchSize = 50; +const cloudProtocolVersion = 5; +const payloadVersion = 1; +const baseFixturePath = 'test/fixtures/strategy_integrity/base-test-v43.ica'; +const baseFixtureSha256 = + '8544873d608a0ad885b2e6042a383596a0b1dc37514034281b4e4eec6168756a'; + +enum _ContentEntity { element, lineup } + +String seedPrefix(int seed) => 'seed-$seed-'; +String strategyId(int seed) => '${seedPrefix(seed)}strategy'; +String folderId(int seed) => '${seedPrefix(seed)}folder'; +String initialPageId(int seed) => '${seedPrefix(seed)}page-custom-shapes'; +String secondaryPageId(int seed) => '${seedPrefix(seed)}page-secondary'; + +List> buildOperationTrace(int seed) { + final operations = >[]; + var sequence = 0; + + void add(Map operation) { + operations.add({ + 'opId': '${seedPrefix(seed)}op-${sequence.toString().padLeft(4, '0')}', + ...operation, + }); + sequence += 1; + } + + for (var cycle = 0; cycle < 80; cycle += 1) { + _addRevisionCycle( + add: add, + entity: _ContentEntity.element, + publicId: + '${seedPrefix(seed)}element-${cycle.toString().padLeft(3, '0')}', + pagePublicId: initialPageId(seed), + payloadBuilder: (variant) => _utilityPayload(seed, cycle, variant), + initialSortIndex: 100 + cycle, + finalSortIndex: 8000 + cycle, + ); + } + + for (var cycle = 0; cycle < 10; cycle += 1) { + _addRevisionCycle( + add: add, + entity: _ContentEntity.lineup, + // A lineup row is keyed by the lineup's id. + publicId: + '${seedPrefix(seed)}lineup-${cycle.toString().padLeft(3, '0')}', + pagePublicId: initialPageId(seed), + payloadBuilder: (variant) => _lineupPayload(seed, cycle, variant), + initialSortIndex: 1000 + cycle, + finalSortIndex: 9000 + cycle, + ); + } + + final secondPage = secondaryPageId(seed); + add({ + 'type': 'page.add', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary', 'isAttack': false}, + 'sortIndex': 1, + 'expectedStrategyRevision': 0, + }); + add({ + 'type': 'page.patch', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary A'}, + 'expectedPageRevision': 1, + }); + add({ + 'type': 'page.reorder', + 'pagePublicId': secondPage, + 'sortIndex': 0, + 'expectedStrategyRevision': 1, + }); + add({ + 'type': 'page.patch', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary B'}, + 'expectedPageRevision': 3, + }); + add({ + 'type': 'page.delete', + 'pagePublicId': secondPage, + 'expectedStrategyRevision': 2, + }); + add({ + 'type': 'page.delete', + 'pagePublicId': secondPage, + 'expectedStrategyRevision': 2, + }); + add({ + 'type': 'page.add', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary C', 'isAttack': false}, + 'sortIndex': 1, + 'expectedStrategyRevision': 3, + }); + add({ + 'type': 'page.patch', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary D'}, + 'expectedPageRevision': 1, + }); + add({ + 'type': 'page.reorder', + 'pagePublicId': secondPage, + 'sortIndex': 0, + 'expectedStrategyRevision': 4, + }); + add({ + 'type': 'page.patch', + 'pagePublicId': secondPage, + 'payload': {'name': 'Secondary final'}, + 'expectedPageRevision': 3, + }); + + for (var index = 0; index < 10; index += 1) { + add({ + 'type': 'strategy.patch', + 'payload': {'name': 'Gauntlet seed $seed revision $index'}, + 'expectedStrategyRevision': 5 + index, + }); + } + + for (var index = 0; index < 80; index += 1) { + add({ + 'type': 'pageContent.patch', + 'pagePublicId': initialPageId(seed), + 'settings': { + 'agentSize': 36 + (index % 5), + 'abilitySize': 26 + (index % 3), + 'useNeutralTeamColors': index.isEven, + }, + 'expectedPageContentRevision': 1 + index, + }); + } + + if (operations.length != operationsPerSeed) { + throw StateError( + 'Trace contains ${operations.length} operations, expected ' + '$operationsPerSeed', + ); + } + return List.unmodifiable(operations); +} + +void _addRevisionCycle({ + required void Function(Map) add, + required _ContentEntity entity, + required String publicId, + required String pagePublicId, + required Map Function(int variant) payloadBuilder, + required int initialSortIndex, + required int finalSortIndex, +}) { + final publicIdKey = switch (entity) { + _ContentEntity.element => 'elementPublicId', + _ContentEntity.lineup => 'lineupPublicId', + }; + final revisionKey = switch (entity) { + _ContentEntity.element => 'expectedElementRevision', + _ContentEntity.lineup => 'expectedLineupRevision', + }; + final typePrefix = entity.name; + add({ + 'type': '$typePrefix.add', + publicIdKey: publicId, + 'pagePublicId': pagePublicId, + 'payload': payloadBuilder(0), + 'sortIndex': initialSortIndex, + }); + add({ + 'type': '$typePrefix.patch', + publicIdKey: publicId, + 'payload': payloadBuilder(1), + revisionKey: 1, + }); + add({ + 'type': '$typePrefix.patch', + publicIdKey: publicId, + 'payload': payloadBuilder(2), + revisionKey: 1, + }); + add({ + 'type': '$typePrefix.patch', + publicIdKey: publicId, + 'payload': payloadBuilder(3), + revisionKey: 2, + }); + add({ + 'type': '$typePrefix.reorder', + publicIdKey: publicId, + 'pagePublicId': pagePublicId, + 'sortIndex': finalSortIndex - 1, + revisionKey: 3, + }); + add({ + 'type': '$typePrefix.delete', + publicIdKey: publicId, + 'pagePublicId': pagePublicId, + revisionKey: 4, + }); + add({ + 'type': '$typePrefix.add', + publicIdKey: publicId, + 'pagePublicId': pagePublicId, + 'payload': payloadBuilder(4), + 'sortIndex': finalSortIndex - 2, + revisionKey: 5, + }); + add({ + 'type': '$typePrefix.patch', + publicIdKey: publicId, + 'payload': payloadBuilder(5), + revisionKey: 6, + }); + add({ + 'type': '$typePrefix.reorder', + publicIdKey: publicId, + 'pagePublicId': pagePublicId, + 'sortIndex': finalSortIndex, + revisionKey: 7, + }); + add({ + 'type': '$typePrefix.patch', + publicIdKey: publicId, + 'payload': payloadBuilder(6), + revisionKey: 8, + }); +} + +Map _utilityPayload(int seed, int cycle, int variant) { + final id = '${seedPrefix(seed)}element-${cycle.toString().padLeft(3, '0')}'; + return { + 'kind': 'utility', + 'payloadVersion': payloadVersion, + 'data': { + 'id': id, + 'isDeleted': false, + 'position': { + 'dx': 100 + cycle.toDouble(), + 'dy': 150 + variant.toDouble(), + }, + 'type': 'customCircle', + 'rotation': 0, + 'length': 0, + 'angle': 0, + 'attachedAgentId': null, + 'customDiameter': 10 + variant.toDouble(), + 'customWidth': null, + 'customLength': null, + 'customColorValue': 4282090230, + 'customOpacityPercent': 30 + variant, + }, + }; +} + +Map _lineupPayload(int seed, int cycle, int variant) { + final id = '${seedPrefix(seed)}lineup-${cycle.toString().padLeft(3, '0')}'; + // One lineup with its origin and landing; each revision cycle moves its + // agent. + return { + 'kind': 'lineup', + 'payloadVersion': payloadVersion, + 'data': { + 'id': id, + 'name': 'Lineup $cycle', + 'youtubeLink': '', + 'notes': '', + 'images': [], + 'origin': { + 'id': '$id-origin', + 'agent': { + 'id': '$id-agent', + 'isDeleted': false, + 'position': {'dx': 10 + cycle, 'dy': 20 + variant}, + 'type': 'sova', + 'isAlly': true, + 'state': 'none', + 'kind': 'plain', + 'lineUpID': '$id-origin', + }, + }, + 'landing': { + 'id': '$id-landing', + 'ability': { + 'id': '$id-ability', + 'isDeleted': false, + 'position': {'dx': 40 + cycle, 'dy': 50}, + 'data': {'type': 'sova', 'index': 1}, + 'isAlly': true, + 'rotation': 0, + 'length': 0, + 'lineUpID': '$id-landing', + }, + }, + }, + }; +} + +List> baseElementOps(int seed) { + Map basePayload({ + required String id, + required Map position, + required String type, + required double? diameter, + required double? width, + required double? length, + required int color, + required int opacity, + }) => { + 'kind': 'utility', + 'payloadVersion': payloadVersion, + 'data': { + 'id': id, + 'isDeleted': false, + 'position': position, + 'type': type, + 'rotation': 0, + 'length': 0, + 'angle': 0, + 'attachedAgentId': null, + 'customDiameter': diameter, + 'customWidth': width, + 'customLength': length, + 'customColorValue': color, + 'customOpacityPercent': opacity, + }, + }; + + return [ + { + 'opId': '${seedPrefix(seed)}base-circle', + 'type': 'element.add', + 'elementPublicId': '${seedPrefix(seed)}utility-circle-current', + 'pagePublicId': initialPageId(seed), + 'sortIndex': 0, + 'payload': basePayload( + id: '${seedPrefix(seed)}utility-circle-current', + position: {'dx': 220.0, 'dy': 180.0}, + type: 'customCircle', + diameter: 14.0, + width: null, + length: null, + color: 4282090230, + opacity: 35, + ), + }, + { + 'opId': '${seedPrefix(seed)}base-rectangle', + 'type': 'element.add', + 'elementPublicId': '${seedPrefix(seed)}utility-rectangle-current', + 'pagePublicId': initialPageId(seed), + 'sortIndex': 1, + 'payload': basePayload( + id: '${seedPrefix(seed)}utility-rectangle-current', + position: {'dx': 420.0, 'dy': 280.0}, + type: 'customRectangle', + diameter: null, + width: 6.0, + length: 18.0, + color: 4280468830, + opacity: 30, + ), + }, + ]; +} + +String canonicalJson(Object? value) => jsonEncode(_sortJson(value)); + +String canonicalHash(Object? value) => + sha256.convert(utf8.encode(canonicalJson(value))).toString(); + +Object? canonicalSnapshot({ + required int seed, + required Object? snapshot, + required Object? folders, +}) { + final normalizedSnapshot = _stripTransportMetadata(snapshot); + final folderList = (folders as List) + .whereType>() + .where((folder) => folder['publicId'] == folderId(seed)) + .map(_stripTransportMetadata) + .toList(growable: false); + return _replaceSeedPrefix({ + 'snapshot': normalizedSnapshot, + 'folders': folderList, + }, seedPrefix(seed)); +} + +Object? _stripTransportMetadata(Object? value) { + if (value is List) { + return value.map(_stripTransportMetadata).toList(growable: false); + } + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + final key = entry.key as String; + if (key == 'createdAt' || + key == 'updatedAt' || + key == 'contentCreatedAt' || + key == 'contentUpdatedAt' || + key == 'role') { + continue; + } + result[key] = _stripTransportMetadata(entry.value); + } + return result; + } + return value; +} + +Object? _replaceSeedPrefix(Object? value, String prefix) { + if (value is String) return value.replaceAll(prefix, ''); + if (value is List) { + return value + .map((item) => _replaceSeedPrefix(item, prefix)) + .toList(growable: false); + } + if (value is Map) { + return value.map( + (key, item) => MapEntry(key as String, _replaceSeedPrefix(item, prefix)), + ); + } + return value; +} + +Map exportIcaRoundTrip(Object? snapshotValue) { + final snapshot = (snapshotValue as Map) + .cast(); + final header = (snapshot['header'] as Map) + .cast(); + final pages = (snapshot['pages'] as List) + .map((item) => (item as Map).cast()) + .toList(growable: false); + final elements = (snapshot['elements'] as List) + .map((item) => (item as Map).cast()) + .toList(growable: false); + final lineups = (snapshot['lineups'] as List) + .map((item) => (item as Map).cast()) + .toList(growable: false); + + List dataFor(String pageId, String kind) => elements + .where( + (element) => + element['pagePublicId'] == pageId && + element['elementType'] == kind && + element['deleted'] == false, + ) + .map( + (element) => + ((element['payload'] as Map)['data']) as Object?, + ) + .toList(growable: false); + + final archive = { + 'versionNumber': '43', + 'mapData': header['mapData'], + 'themePalette': header['themeOverridePalette'], + 'pages': pages + .map((page) { + final pageId = page['publicId'] as String; + return { + 'id': pageId, + 'sortIndex': (page['sortIndex'] as num).toInt().toString(), + 'name': page['name'], + 'drawingData': dataFor(pageId, 'drawing'), + 'agentData': dataFor(pageId, 'agent'), + 'abilityData': dataFor(pageId, 'ability'), + 'textData': dataFor(pageId, 'text'), + 'imageData': dataFor(pageId, 'image'), + 'utilityData': dataFor(pageId, 'utility'), + 'isAttack': (page['isAttack'] as bool).toString(), + 'settings': page['settings'], + 'lineUpData': lineups + .where( + (lineup) => + lineup['pagePublicId'] == pageId && + lineup['deleted'] == false, + ) + .map( + (lineup) => + ((lineup['payload'] as Map)['data']) + as Object?, + ) + .toList(growable: false), + }; + }) + .toList(growable: false), + }; + + final encoded = canonicalJson(archive); + final decoded = jsonDecode(encoded) as Map; + if (canonicalJson(decoded) != encoded) { + throw StateError('Canonical .ica JSON did not survive a JSON round-trip'); + } + return archive; +} + +Object? _sortJson(Object? value) { + if (value is List) { + return value.map(_sortJson).toList(growable: false); + } + if (value is Map) { + final keys = value.keys.cast().toList()..sort(); + return { + for (final key in keys) key: _sortJson(value[key]), + }; + } + return value; +} diff --git a/tool/convex_client_gauntlet/runtime/pubspec.lock b/tool/convex_client_gauntlet/runtime/pubspec.lock new file mode 100644 index 00000000..ebe381fb --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/pubspec.lock @@ -0,0 +1,457 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build_cli_annotations: + dependency: transitive + description: + name: build_cli_annotations + sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + convex_flutter: + dependency: "direct main" + description: + path: "../../../third_party/convex_flutter" + relative: true + source: path + version: "3.0.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_jsonwebtoken: + dependency: transitive + description: + name: dart_jsonwebtoken + sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + url: "https://pub.dev" + source: hosted + version: "3.4.1" + dartvex: + dependency: "direct main" + description: + name: dartvex + sha256: "7a343c5853f25a1a136051d2d37002a0e1e3f6c230b6f24560797880de33b5d8" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_rust_bridge: + dependency: "direct main" + description: + name: flutter_rust_bridge + sha256: "37ef40bc6f863652e865f0b2563ea07f0d3c58d8efad803cc01933a4b2ee067e" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + functions_client: + dependency: transitive + description: + name: functions_client + sha256: "94074d62167ae634127ef6095f536835063a7dc80f2b1aa306d2346ff9023996" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + gotrue: + dependency: transitive + description: + name: gotrue + sha256: f7b52008311941a7c3e99f9590c4ee32dfc102a5442e43abf1b287d9f8cc39b2 + url: "https://pub.dev" + source: hosted + version: "2.18.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + jwt_decode: + dependency: transitive + description: + name: jwt_decode + sha256: d2e9f68c052b2225130977429d30f187aa1981d789c76ad104a32243cfdebfbb + url: "https://pub.dev" + source: hosted + version: "0.3.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: "direct dev" + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + postgrest: + dependency: transitive + description: + name: postgrest + sha256: f4b6bb24b465c47649243ef0140475de8a0ec311dc9c75ebe573b2dcabb10460 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + realtime_client: + dependency: transitive + description: + name: realtime_client + sha256: "5268afc208d02fb9109854d262c1ebf6ece224cd285199ae1d2f92d2ff49dbf1" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + retry: + dependency: transitive + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + storage_client: + dependency: transitive + description: + name: storage_client + sha256: "1c61b19ed9e78f37fdd1ca8b729ab8484e6c8fe82e15c87e070b861951183657" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + supabase: + dependency: "direct main" + description: + name: supabase + sha256: cc039f63a3168386b3a4f338f3bff342c860d415a3578f3fbe854024aee6f911 + url: "https://pub.dev" + source: hosted + version: "2.10.2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + yet_another_json_isolate: + dependency: transitive + description: + name: yet_another_json_isolate + sha256: fe45897501fa156ccefbfb9359c9462ce5dec092f05e8a56109db30be864f01e + url: "https://pub.dev" + source: hosted + version: "2.1.0" +sdks: + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/tool/convex_client_gauntlet/runtime/pubspec.yaml b/tool/convex_client_gauntlet/runtime/pubspec.yaml new file mode 100644 index 00000000..e31de1b0 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/pubspec.yaml @@ -0,0 +1,24 @@ +name: icarus_convex_runtime_gauntlet +description: Isolated symmetric runtime comparison for Icarus Convex clients. +publish_to: none +version: 0.0.0 + +environment: + sdk: ^3.9.0 + +dependencies: + flutter: + sdk: flutter + convex_flutter: + path: ../../../third_party/convex_flutter + crypto: 3.0.7 + dartvex: 0.2.0 + # convex_flutter's generated Rust bridge is pinned to this runtime version. + flutter_rust_bridge: 2.11.1 + http: 1.6.0 + supabase: 2.10.2 + +dev_dependencies: + flutter_test: + sdk: flutter + lints: 6.0.0 diff --git a/tool/convex_client_gauntlet/runtime/results/convex_flutter_correctness.json b/tool/convex_client_gauntlet/runtime/results/convex_flutter_correctness.json new file mode 100644 index 00000000..03d2ae0d --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/results/convex_flutter_correctness.json @@ -0,0 +1 @@ +{"schemaVersion":1,"status":"passed","adapter":"convex_flutter","candidateVersion":"3.0.1","flutterRustBridgeVersion":"2.11.1 pinned","deployment":"local:127.0.0.1:3210","gitCommit":"fb83488c0924f8daf57c4bdfc48d7a4a5ff0c8f5","baseFixture":{"path":"test/fixtures/strategy_integrity/base-test-v43.ica","sha256":"8544873d608a0ad885b2e6042a383596a0b1dc37514034281b4e4eec6168756a"},"seedCount":50,"operationsPerSeed":1000,"totalOperations":50000,"allCanonicalEqual":true,"allResolved":true,"processRestartCheckpoint":true,"bytesSent":20224647,"bytesReceived":49392642,"wallClockMs":74446.106,"maxRssBytes":266141696,"machine":{"operatingSystem":"macos","operatingSystemVersion":"Version 26.5.1 (Build 25F80)","processors":8,"dartVersion":"3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on \"macos_arm64\""},"seeds":[{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":true,"exercised":true,"freshTokenAcceptedMs":101.769,"manualReconnectMs":34.005,"postReconnectAcceptedMs":156.172,"queuedBatchReplayedExactlyOnce":true,"reconnectCalled":true,"recoveryMs":156.18,"refreshSessionCalled":true,"rejectedTokenObserved":true,"tokenChanged":true},"batchLatencyMs":[44.707,41.851,42.265,45.106,42.297,45.437,44.446,48.175,46.64,47.419,29.093,49.055,50.128,51.137,64.476,52.093,66.192,67.051,45.383,44.0],"canonicalVerifierHash":"26d71e8df48fba1f7aae2c8cf4b5569e8b9360166b9bb6ed887ce7f14048f2f7","duplicateNoopHash":"2f5c83b805fd22471e866932f0ad3a6b438850368d79cbaada4e91a2306d36f8","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":10,"fault":"auth_reject_refresh"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"b4e244c38b89789f1191e988768aa681f4722184533077ef58eb6f95ee6a0e52","faults":{"authRefresh":true,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":92.329,"rejected":90,"remoteConvergenceMs":18.382,"retryCount":0,"roundTripHash":"57f03a40845f1cbcec927c4a2abc68781fcec79e10d5f8a896a7deb95bbd9d69","seed":0,"traceSha256":"ddf6d41ed9ccdbf3c60766fe6b0318218dd8954d8615fcffb2a8daefe849aa06","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[45.949,43.107,44.078,44.854,44.476,46.678,46.306,47.012,47.885,48.469,48.366,50.443,50.903,51.73,57.76,53.462,67.383,69.322,46.16,43.849],"canonicalVerifierHash":"521b140c0a612b8ea44a1d9b4b03cbf0573799ae00bf05498122bacab81c1bba","duplicateNoopHash":"f5cf17c272add3c1c7d3460a3f29c4ee4bf786dd0d24d00d5010fd3069450206","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":7.239,"rejected":90,"remoteConvergenceMs":11.252,"retryCount":0,"roundTripHash":"42034206a0ce276524b5f6c4b61270d036eafa3ea888f6af32fcc8898136425d","seed":1,"traceSha256":"74e1c135f93f0e9ebf7029a5222aaa38b6aa0f834e31861e9e3de8515922bca4","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[47.552,43.031,42.595,45.482,44.983,46.66,47.505,47.618,48.446,48.478,49.09,50.487,52.513,52.6,65.687,54.778,67.709,70.963,47.278,46.359],"canonicalVerifierHash":"9ca1893e49091976796548e8568b4e1791bff1f72f5f883892ef688a59483304","duplicateNoopHash":"f241cf2843f3adfa94f6566c00df83a1f234b4993b73daa1fe08ff8b34e89260","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":87.211,"rejected":90,"remoteConvergenceMs":10.05,"retryCount":0,"roundTripHash":"44c8e21bbcb1c01d01bd1446150e3c5e3daa0f256aced3da4e96ac1afeb6be93","seed":2,"traceSha256":"2b3e81b55c925a72176cd6f3d1515c7063d7706d8d4381c641b6fc0a8e121262","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[49.714,43.877,43.909,44.893,46.781,47.5,47.647,48.754,51.648,51.527,52.444,52.231,52.838,53.858,60.792,55.667,68.129,77.984,64.097,47.918],"canonicalVerifierHash":"197d1fc1c62be082b93dd2c51021a15c68e3f7551de5e0d949b11152f6ab6840","duplicateNoopHash":"1e34802380fb4c7b66dfe194d29cf6bb4a223e342c5ac58fb77bd10b9436b658","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":49.407,"rejected":90,"remoteConvergenceMs":9.995,"retryCount":0,"roundTripHash":"18bb3a755432638399f32b7fb39627dab2c7c474726dc01b433243d45e86ab8f","seed":3,"traceSha256":"f7105a396da40cd883ec4cc9e83f1a6cb53706e90e782cc3e71cb778bf252235","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[49.966,47.689,47.367,50.407,49.031,51.155,49.532,57.789,52.725,51.745,54.655,51.385,56.104,58.176,61.634,57.892,71.717,72.04,49.315,45.583],"canonicalVerifierHash":"472d3b636388ed4b3ce98242c337cdca98191dd93feeaf23b2f0bf72c2068232","duplicateNoopHash":"94cb107907f89f5fc3a85d810566e5f67ec06498ef9345e4236bce4c79443170","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":25.947,"rejected":90,"remoteConvergenceMs":10.553,"retryCount":0,"roundTripHash":"6bc869c98139d476a3b61de2ca80637ca4d956c2e99008462e6667110516d5e8","seed":4,"traceSha256":"f8cc2d86c82f00b22442e2b457b962f2e31d958be26bd26999c5035502d44d52","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.041,43.703,45.469,48.68,46.008,47.216,46.827,54.289,49.271,51.328,50.376,51.61,54.378,54.653,62.98,56.145,69.919,71.891,47.321,46.863],"canonicalVerifierHash":"60e119554dd2c84670ff85ad1f4743fe3b39bb2367a81ecfa4f978cd7e834be1","duplicateNoopHash":"69a9e28c6ba4259807f9135260188fb48763db7cd60da9f0bcbb2df689ac7fec","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":42.08,"rejected":90,"remoteConvergenceMs":9.685,"retryCount":0,"roundTripHash":"3e1eb93aba491b833fe657b72a49cb08871e51b6abd93ced58a16b4c1bb1d714","seed":5,"traceSha256":"d0e377de6c9acd5bde977020ce6b5a3b79f41ba792748eeb87617f0bbfe67073","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.553,47.819,46.304,47.833,47.204,48.559,48.989,50.424,52.12,52.562,52.797,53.893,55.107,54.868,60.989,56.504,71.466,71.321,48.614,48.095],"canonicalVerifierHash":"870bdc3fe241a36e4cafa9cd2db6550900db537148df0012129a4fc729168e44","duplicateNoopHash":"7b6d6f868d69e5c19a34b1ef7a5da5bd85c7b2c0de6b278023971d5125376186","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":29.016,"rejected":90,"remoteConvergenceMs":9.674,"retryCount":0,"roundTripHash":"a6d78c0173ff37ca01ce546bd371b25102dff424fc71e5ed158da8cc3afb9409","seed":6,"traceSha256":"4089e29162d4a1f4eeb277698a40e77dee654c5e6b024de882412828b2b868be","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[49.386,45.44,46.348,48.344,48.597,47.535,49.196,50.188,59.507,59.799,55.295,53.177,52.439,55.211,62.945,56.297,73.11,70.554,50.714,46.563],"canonicalVerifierHash":"07d665cbffdf875c025bb158a561350943ae3fbd5068762fd11819a6a4901942","duplicateNoopHash":"abc350378349c0c5ca8ac8718147350b24bba91d01f1b3584ac0ae8728392bbb","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":19.424,"rejected":90,"remoteConvergenceMs":10.021,"retryCount":0,"roundTripHash":"e9e701572c478eb4ff07bdc25b9686439e87f671b6912b55edab9f6640bd9348","seed":7,"traceSha256":"2f479b6ed6743dedae4a36d95c58c95f225ba529fcd863e5640f49afc5e838b7","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[49.294,43.982,45.761,47.83,50.264,48.767,48.654,50.564,51.155,63.965,62.378,56.951,54.38,56.56,67.467,59.287,74.27,78.166,118.919,47.722],"canonicalVerifierHash":"efc2b0c123c804ed7f1847b3cfaec274bad1ea5ae14b606f04cc0a565458437f","duplicateNoopHash":"7c4a10f6a44922af7b90a06f9e3796d4915b37934f1958e659c352ef988032a7","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":85.26,"rejected":90,"remoteConvergenceMs":10.311,"retryCount":0,"roundTripHash":"2381d28c5762232a05a96531e03aabc0b196671f5f4c493cfb8a2bc1e16fed87","seed":8,"traceSha256":"18d04c6a59b74a9026ab9d6f69161a1ae3101de4fe16acc08b831a885a6a301f","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[50.295,43.717,47.616,49.941,47.425,49.697,49.079,51.88,53.159,52.308,52.199,53.113,54.791,56.474,60.777,55.946,70.53,70.857,48.277,48.61],"canonicalVerifierHash":"703442e3b93e7bc929da059e86ab44de647f270cefc120656f501966b6847100","duplicateNoopHash":"acb46046274da0c9262f4910b29e4fda0a2bfe3132374a35db02c9f2f89398f4","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":38.728,"rejected":90,"remoteConvergenceMs":10.032,"retryCount":0,"roundTripHash":"e63096e423e4fa5de04d55dd992a191a939e288f22077b5dba9f1f30d0312ac0","seed":9,"traceSha256":"10e7fe955f927f3626b5586eff07c5c452e7dd8fada065f6ea12839907ebfb8f","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.633,46.5,47.956,52.804,49.096,51.777,49.572,50.496,51.733,52.54,53.009,53.555,53.172,55.874,68.72,55.392,72.094,73.195,48.905,48.505],"canonicalVerifierHash":"0dedd28fa52610998313b57e5e621be6488408b8475b8082434152a7e4bfffde","duplicateNoopHash":"68de0d932dda40e9fa94ad87bce5062194f4b64d032743224b09543a35fadb04","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":89.362,"rejected":90,"remoteConvergenceMs":10.218,"retryCount":0,"roundTripHash":"e199bf3ee7ce8299f481c487ecb563b9bc5a22508107847ee32280f5af2799fa","seed":10,"traceSha256":"3aedbcc55f63d2e40d1053713da07189c675eeedafc45557893bfb44a4a4a3fa","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.773,45.342,45.934,48.629,47.559,49.479,51.342,50.529,51.542,52.331,53.87,55.72,53.667,56.686,63.206,56.797,71.028,72.407,49.82,49.224],"canonicalVerifierHash":"976df9bc169579795add78059b22aecb15add1a77fa132a762b81256d7a14ab9","duplicateNoopHash":"ecd01e95d0ba98e4c1033dd1c11ae805f94ea9c57caa7eeb3883adbdba18baa9","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":55.064,"rejected":90,"remoteConvergenceMs":10.061,"retryCount":0,"roundTripHash":"6b2a4c8cae985614914a1122c691c7976d2f5fe6bc30dca6a2a54e7a718ebbdb","seed":11,"traceSha256":"d64e19b329a28294c7e145d5eea4d659b86b00fdc478f7472e57f1b93de378ef","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.652,46.145,48.342,49.45,48.894,49.412,50.149,50.646,50.096,51.758,52.675,55.359,57.378,56.341,63.875,58.98,71.401,73.779,50.545,47.715],"canonicalVerifierHash":"fe27618043429656abed9f1810fd3f7fe21fa7844cfe474e4ee27170aa6ba761","duplicateNoopHash":"75cb591df89ce4872e5d71150982ccb110460036da1a031d13af69b1a497bf44","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":58.156,"rejected":90,"remoteConvergenceMs":10.104,"retryCount":0,"roundTripHash":"08db87f541a6ad80969ae614cb3c192ee4668e76d650a3c3c83ae2205d7cae2f","seed":12,"traceSha256":"56270e40851c311cafe2638b7bdf9e162ca4190285215f3a788c34ab68e9fbfa","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[50.697,47.152,46.472,56.983,49.683,51.149,64.029,52.122,52.172,52.643,54.316,54.731,55.074,56.358,59.82,58.785,72.603,73.568,51.587,50.762],"canonicalVerifierHash":"1eb6c99489819977b8c62d4ac7cc2b6332253eea89c0028a0d00f543ccc80b4e","duplicateNoopHash":"a2780357577e00a64a3d6882842034f3c7a33a7fb73f76a1bb513582adc82f48","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":11.061,"rejected":90,"remoteConvergenceMs":10.052,"retryCount":0,"roundTripHash":"30a8267dc6ce65b39cf9e2f8e9d47c425c22d34ae75df7a6427dcfbf320b49ca","seed":13,"traceSha256":"04fbda33461bbb975b9e66fc1ff0fe176931fdbd9c56fe8d3b9f9abcc939ab07","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.295,46.698,48.278,49.61,48.686,49.284,51.029,51.361,51.887,342.716,56.099,59.679,58.2,59.593,70.269,60.887,78.985,74.897,53.681,48.627],"canonicalVerifierHash":"9f4f93499745ce94e2ea84804d41cdd24007491f9ad333f6994c23adaca1a97f","duplicateNoopHash":"6a79d43b71c80e3ece66ab93693e6111df9ac8633c5e8af7dfbcdc86f73f16e9","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":78.161,"rejected":90,"remoteConvergenceMs":9.844,"retryCount":0,"roundTripHash":"4406199eb581980a79d880cbb483fd97b1ac2b9ecf927ac3d48a1ede8f3a8ca6","seed":14,"traceSha256":"d105bcc5f7eaa8a768bd666c3d6fb072fb97a0191a935d70e688d086e52f95c4","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.666,47.994,47.75,50.615,53.284,51.322,50.478,55.45,52.215,55.575,55.355,53.413,54.262,55.339,63.444,56.189,71.806,73.609,60.895,49.873],"canonicalVerifierHash":"e44921495d13f7e764fb5170d992c6806add23c1ee2a902d2ed586e78b6518f2","duplicateNoopHash":"89f952d57ff5c744f32966f233f39fdf76ff3729bca2ffb22da379572bc772cb","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":53.214,"rejected":90,"remoteConvergenceMs":8.241,"retryCount":0,"roundTripHash":"885dcea93a305a0527b65c09ab8a51e42eeab8486b0a17a812adfc8a23dd0692","seed":15,"traceSha256":"fd2509f037ad595831e5af5de358a045a4944c5070aeccceca9a85e5d93f0dcb","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[53.93,46.824,47.308,51.829,54.307,52.601,57.036,51.022,51.847,52.357,54.543,54.229,56.829,55.343,63.123,55.565,70.102,73.997,50.979,48.806],"canonicalVerifierHash":"fcab45e94cbb1dd32f40022c4f2538d8a8921f6f9a993c44da5a1af856273521","duplicateNoopHash":"1a2c13daad7b021d5bf7aba90696fb2fba2a5479ff1fa49027c88e3b5e9bf237","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":19.791,"rejected":90,"remoteConvergenceMs":9.784,"retryCount":0,"roundTripHash":"23079740e8c81056d542e80219368de85f2c9ae8740e9db95182ac2d1433a5cf","seed":16,"traceSha256":"15974b2b6d314a57e29fe393296085940f83539b4485eb9ada849149db7a99cc","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[53.846,47.727,48.758,49.36,50.423,56.684,53.109,52.369,52.503,52.931,53.925,55.136,55.665,56.68,59.708,57.135,71.4,72.471,48.577,48.46],"canonicalVerifierHash":"0ae80e9274783e32170474a495f3c31bfa702e9acbe970cb1a04c439846eb86b","duplicateNoopHash":"834e79323bda37030a4c6af90b9a0dadade53242cd9801ba090d12a86054e645","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":7.261,"rejected":90,"remoteConvergenceMs":9.864,"retryCount":0,"roundTripHash":"5ebbea09bcb5c13323f4a6dea81b146de93f7fa134b025b2163ca48c62222abc","seed":17,"traceSha256":"53cce4ffa3236c18592a997edd684f10596e8cdceacd8402d858c284a7d9054d","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[50.608,47.559,47.477,50.158,49.069,48.533,51.221,53.199,51.877,52.813,53.072,56.713,56.292,58.505,69.121,58.484,70.911,74.338,49.988,48.599],"canonicalVerifierHash":"f3fd473e5c7029efed7f8b2b39933c0240a36cf83a5eb3286adfe377c9890da5","duplicateNoopHash":"2328c71bfb35d1fc60079b4c3572d96fac5e48f92fa4fee9baa2f9a469aa7404","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":80.278,"rejected":90,"remoteConvergenceMs":10.318,"retryCount":0,"roundTripHash":"9f8e4e09c1123b52138e32282eeb53f1d13c8317d8f3b2cda0cb11c576704704","seed":18,"traceSha256":"a542c21db854c18f4cdcb385e6ac298bf7af6282c0972913cc8fce70aec86847","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.778,47.094,48.674,50.543,48.474,50.827,50.105,51.601,71.425,52.574,53.202,53.139,55.747,56.322,60.965,58.444,70.508,72.585,50.806,47.614],"canonicalVerifierHash":"63e156ef82374f25fc17ac38cd7fb0ffa365fa4d6fcd29f42be9a52f729ab158","duplicateNoopHash":"1121a351ad1dd9adce4685ffd6e719a32e98c70594e4a09f928d6f996e9d1207","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":15.003,"rejected":90,"remoteConvergenceMs":8.472,"retryCount":0,"roundTripHash":"3bf2bcb94fab05718ea34aed383efbf9e411b10d95a6179ae911f90d6a57cb1a","seed":19,"traceSha256":"0d92405289eea8b6bef336eab7f6bd29373c9dfcab013fe1126707d0fc4cb0a6","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[50.719,47.414,46.352,50.768,48.879,49.857,51.086,51.858,70.589,65.424,60.415,60.606,60.169,56.609,69.849,59.173,71.19,75.431,70.461,49.025],"canonicalVerifierHash":"c8bdffb3ea12e788eb2a5088294f17e8705320e3c734ca0302d660b841565e56","duplicateNoopHash":"661be1cf36a3e6dfd57dfc2f78cfaaa70d9b9adf2badc7d67b4c20ac4a528c24","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":72.088,"rejected":90,"remoteConvergenceMs":9.796,"retryCount":0,"roundTripHash":"268efccad0ace19c28dc92d9c54b320c38ac129eae3a5b5f6710d15562fa7f3a","seed":20,"traceSha256":"810f5993000813418216c7c55f6a64a15516e1b6f63e77e55dc9d0beb326a59c","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.731,47.17,47.225,50.083,50.141,51.479,54.03,53.397,52.526,53.222,52.121,55.13,57.875,56.434,69.831,56.339,74.833,77.678,49.028,49.065],"canonicalVerifierHash":"c26d7da5ac7f287f6edff0b7633a49ceba13692f2421bf0738cd6bb044c5428e","duplicateNoopHash":"e1ca213dd003e35e7e8cb0e94b3cc3c65a6284240156492d42496a39c0216155","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":96.348,"rejected":90,"remoteConvergenceMs":8.193,"retryCount":0,"roundTripHash":"4d284bde91877dd19f45ec5357c0bbc3213b125ea5a2ab460aac161c834e56d1","seed":21,"traceSha256":"00514f57b0533bafeeb8cef85306163b05977fd156fb8d4f6e8971f43cd5c3d9","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[51.182,47.375,48.427,52.003,50.438,50.932,52.804,53.681,52.292,52.469,54.223,54.546,55.766,56.205,62.701,56.999,73.536,73.151,51.871,48.887],"canonicalVerifierHash":"7dc98e0042f910e161dc48692e61b71bee6f5f3c4e2c185947bcd346651c62c1","duplicateNoopHash":"0c84e6481a015a570d0181acae33fe459537c904b339fab12049bfcb9f98024a","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":10.958,"rejected":90,"remoteConvergenceMs":10.08,"retryCount":0,"roundTripHash":"8af6d9079e0d0da10a62772b0ec081dc064e6480c6545292713e51b5ee9afeda","seed":22,"traceSha256":"3b219f2a009454e6a7e145c270ea4540712ac42a2e64a3de49decb95d00cb6ec","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[52.834,47.695,49.879,49.493,52.11,50.887,51.601,51.303,52.565,51.975,54.821,54.489,55.559,56.603,64.082,57.817,72.343,74.53,50.789,49.613],"canonicalVerifierHash":"1f42cdfc51ff0c4a3d156d71d7af4941eda5ef046969f200459a8dfcf0df4594","duplicateNoopHash":"4a4946b0e8eb9183e6b90e464571b002e3053e95ed6510dcb24e619868058604","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":44.202,"rejected":90,"remoteConvergenceMs":9.701,"retryCount":0,"roundTripHash":"d9eb0ae47ebe116de7ffe955c9782bbc6cf6f1bacb1ff2cb44ad3528939d70d6","seed":23,"traceSha256":"a44400c0327fe034c8e1d489d65d4a1e8444a312806565991f9f90c2e7bd2427","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[52.601,48.16,47.902,50.116,47.746,50.23,51.256,52.687,51.833,52.51,54.943,56.818,59.349,56.803,68.451,59.044,72.374,73.593,51.188,49.567],"canonicalVerifierHash":"77478c60c3ae9cd45fc8dcda6a71cd0010be62120387e64242c60b1eb7c87a4e","duplicateNoopHash":"089b4218683a43b11f6a2fb52d4b660cd1db966fe14a98a652aba04a95c23a78","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":72.942,"rejected":90,"remoteConvergenceMs":8.624,"retryCount":0,"roundTripHash":"0001e26052bdd0bab5724856e8de207155cb84a9d448d7aef7c173ba4e4a96e3","seed":24,"traceSha256":"dbd479d2d15a8febd16d5f86b91f7273d4c05d57076b180f1daa3d318d89b39a","unresolved":0},{"acknowledged":910,"adapter":"convex_flutter","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[52.819,46.338,48.011,49.366,55.584,49.786,50.477,53.395,52.454,53.099,61.065,58.814,70.586,67.029,68.546,62.33,79.742,76.385,74.469,51.416],"duplicateNoopHash":"610676792608b1dd1f2e06f6dc22864d82873dd8f8cbe86ef906a8174a708d0c","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"},{"afterOperation":500,"fault":"process_restart"}],"faultScheduleSha256":"d680df6635276d5593cea5cf988ac54915d8810167aae2037195424564f25e44","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":true,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"operationCount":1000,"rejected":90,"retryCount":0,"seed":25,"traceSha256":"22a209a7f9723602fadb0c3baab2d73b8a7cef893297ade4cd87a2d61122cd5b","unresolved":0,"reconnectToLiveMs":30.004,"canonicalVerifierHash":"0b7840fc3e7157c541d7862e398acaa57ac57628f918e516746483093abf6745","roundTripHash":"44ae786ea3c9385ee3d3d5321fd63b6222cca1ea07240a307559cdbbbdbecc19","remoteConvergenceMs":39.145,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":26,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"9c530a068ba1382fbe34f6b3bd558672f8f4538963b8ad673e4be834103e93bd","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.745,53.494,51.657,57.322,54.544,51.922,50.729,53.488,53.581,54.145,57.771,55.506,57.69,58.869,62.922,57.807,75.719,74.213,51.179,48.136],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"119ebe1f480b6c9f5df8f95cab240265bd09233c3f4b5dcd923728ef667b7a16","reconnectToLiveMs":29.068,"canonicalVerifierHash":"b8fc7d69a834b62d4a614de02cebbde60f3a9ccb93b4bec1f27e9987beced0ce","roundTripHash":"748fe55957a8fe14588430c1b62e80c9bf9bb046656f3dcd48d1691f1b33b54b","remoteConvergenceMs":9.675,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":27,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"0135b2eafc700e90fcbcd47b19e5a1d34ab5e99f255b4b315e2161c2896ef910","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.684,46.976,48.29,49.928,48.676,51.475,51.646,53.141,52.541,54.143,54.734,56.506,58.438,58.31,67.949,58.425,73.844,75.157,50.36,49.724],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"4ad42017a48dbc8e12d942d945792c060603e08d9b7d18872b2158673ad23d7f","reconnectToLiveMs":71.22,"canonicalVerifierHash":"16a2f4cfba0166cf6435be3b64f11efbcaa06678f991140dbacc8d4e32097661","roundTripHash":"3c1ab0d073cd7610b4cd4a7c6521c0b5e47e4534104feda430f35e148f39f367","remoteConvergenceMs":8.298,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":28,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"03fd5bbcfd5789fb329015b4ca3015f10c5322f782f41d1a33c8739dcc250103","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[54.319,47.185,48.258,50.007,48.943,50.39,52.32,52.582,54.774,53.948,54.813,57.461,59.425,58.997,74.438,57.444,70.804,72.804,52.724,60.161],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"2d62e883551de7eef0494e8e419eb251320287cfd85221fa771a2ec7467bb810","reconnectToLiveMs":81.342,"canonicalVerifierHash":"b990e106461c9e852b53e3be3e6c612b61f79bacc9e5c999f8ef1de308a108a0","roundTripHash":"8231af29d3d0c7601470ec0da754f2f0fded387c6bbda084509076505efb0e9b","remoteConvergenceMs":8.488,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":29,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"8c898c274aa218bfce9e6a4f758d9273e1401b0edeab0db3d648e233aef3c687","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[55.291,53.452,48.97,50.992,47.942,51.41,51.042,61.883,51.797,52.185,54.773,55.174,57.773,56.822,62.322,58.36,73.321,73.703,49.152,49.29],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"862b67674912aa8d134be72444f4cdb9a5b50c33276e2a90ef4ae87f302351a8","reconnectToLiveMs":7.272,"canonicalVerifierHash":"abd743722679bd1335b1944cd1a5e315a89097e014d9b43d57957012d766e41a","roundTripHash":"6954dfaff2a7993d6a4d77208f9bb004083a5722931341643974ed81212fa7e8","remoteConvergenceMs":8.062,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":30,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"222c0dc904bf299d49547394de05a001ecc68710bc02a1b311b4beafb0beb019","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[51.422,47.152,48.34,53.457,51.051,50.34,50.417,53.342,52.676,55.709,54.993,54.686,57.345,55.661,63.98,59.644,91.792,73.566,54.22,50.021],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"d0cd4491b844b50e85c3cbec56f3095662a981c0b8b695d7c85637b0e269aad1","reconnectToLiveMs":23.102,"canonicalVerifierHash":"1894c1445629a6547704f4dfd8bb17797b825eb585c520264c5c08dbc6366d77","roundTripHash":"bb887ac1fe5cae5ba0f2625c2af2b79493cb3fd885fdd8e1560a3aebe032b712","remoteConvergenceMs":8.524,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":31,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"c3760666249cd5b3679cb726fd60c15bde3e2a9bd079a7800fa297f824b7d339","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[51.915,49.157,49.165,52.792,54.015,53.038,52.171,54.662,51.894,51.815,54.904,53.953,57.294,55.846,62.601,60.022,73.284,73.928,53.398,48.064],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"206eaacdceff3df5532e76f3e5adc4964146e8504a64cbfddb43fabf4fce28ef","reconnectToLiveMs":9.788,"canonicalVerifierHash":"47d58b681022fd8ecc3b63238639a194262bef74422dd577da50fc761551d216","roundTripHash":"1ef7b56e658c8f1715f96246a583228c7b022d4e53b86374934a608bda3567b1","remoteConvergenceMs":7.995,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":32,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"f8a1efc44c616e628d61be595d4b092755c5da9e4347176fbf48421b326056c1","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.783,47.955,48.072,50.274,50.497,51.756,51.801,53.193,53.228,52.65,53.67,54.815,59.941,55.873,66.93,61.075,75.172,73.193,51.543,49.645],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"9f583fc4b098f02824bb5fa50c8ed9a76709c8850a94de973e02fe98003010ee","reconnectToLiveMs":57.18,"canonicalVerifierHash":"ef6d53a40b328a9729ca3c0157aa0adbe1c7cb390ab38e0c6949e03aa9a45147","roundTripHash":"b5389d9f11296e131badd2a6f0a299b125227ebc6b542936d07d8d3fe2b0c7d0","remoteConvergenceMs":8.252,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":33,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"53b09101de072e7f39e1f605b4e64c528f99951f9ec10abc33901a08def7d6ad","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[55.553,49.339,49.28,52.439,51.929,50.983,51.145,52.802,51.614,54.961,54.944,55.264,56.502,63.448,73.684,57.573,73.054,74.641,52.254,49.91],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"0c9c2a625372e9dea51750e620c118b2702bab4e7484530ad1bd6872097db785","reconnectToLiveMs":102.382,"canonicalVerifierHash":"495cf6e87b63acba4ec386a2f17165d796435465d45f391c4cf212781f7eca5b","roundTripHash":"7a43f69fdf62c9f83ba733e4b8a6488902860126dc015c5037cd8ea03da60bde","remoteConvergenceMs":8.348,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":34,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"41039af22db14b082c0f8d27d0f337109516f3c0ff5395bbdbd52c3f2a73e366","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[52.417,46.785,47.867,49.984,49.108,50.718,52.081,53.852,53.585,55.537,55.205,57.244,55.598,59.088,72.131,57.788,74.259,76.373,237.73,54.525],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"1a24ce7e3c28f5807015976bbfa3e712f6def2a8d3b8f8ed87dfb6d2903f154b","reconnectToLiveMs":87.245,"canonicalVerifierHash":"cf93ed2b3ec0f97763ae4ab9f60906018563a26a37da1abcd4ea47f5a3b27149","roundTripHash":"1fbeea46ef7e1780b7fe8db03d2cc35b54a8e469a4fd1047a159498bfb643161","remoteConvergenceMs":8.364,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":35,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"b9275a1393e700c6b85ebf43295ee9fbf7e0215d3e64d41b229fd4d0d1d16252","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[48.947,49.034,49.876,57.628,56.987,53.885,50.78,52.088,53.213,55.365,54.32,54.576,57.691,59.187,64.926,61.792,70.686,74.156,49.07,49.296],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"519d837de5b7f860793ea33c7269be33f2d3286b79c6e9e9507418df34faf035","reconnectToLiveMs":19.045,"canonicalVerifierHash":"887321746985c1c8bd0822300fe6373a4ef21380ef5ecc9c335f47e8cf9a8271","roundTripHash":"b9cea9c0d0df6e43effaaff688e7e8b7dcea72d7f88d91df5d70c9dcd51b3ece","remoteConvergenceMs":8.516,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":36,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"c0c7e0207a6bcd0957b602550570e42db42800fe57c66626010f25242d5f8208","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[51.72,47.037,49.46,51.551,49.767,50.85,52.254,52.484,51.625,52.109,53.737,54.589,56.407,57.635,71.458,58.872,70.89,73.648,49.386,48.391],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"44641ee26407719b887dc44016ae6afe719ff28a61bf2ac2adff13418a002f2d","reconnectToLiveMs":83.212,"canonicalVerifierHash":"b25494e6ef17124b508fe8d47b13d055664336898cf2192f495b01b2037d7c0e","roundTripHash":"d14c83263732d3d4ce5c1ddffa521f4a8e139a0115a0b13204c75c5d5ded723f","remoteConvergenceMs":8.517,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":37,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"78c97698051da2fb983461e5c8f6d2d3bd357afb10e6d42cf9918b98c8804b5a","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[52.038,50.216,49.314,51.184,49.308,50.59,52.238,52.357,59.577,56.476,53.832,55.869,56.96,56.562,61.491,57.635,72.316,74.246,51.115,47.622],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"36b5339d6a5239af0c2e64c5a19e64b084ad9ce6a27851969945465a67dbb365","reconnectToLiveMs":19.025,"canonicalVerifierHash":"c9bd2baab0265dc7a190e0cd455f13e2575f7a04ebe2a01afecfb5d78ea1cc65","roundTripHash":"6ddbcafed928297a094e02cca9d9201fd12c225dd55b7df8a267aadd5e671715","remoteConvergenceMs":8.072,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":38,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"3f2dafd0bc96122b9fd4274b15b67a6834f00aefcce0e9fd414dc56bee7ee67b","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.128,47.358,57.054,52.929,49.732,54.572,52.616,73.347,88.966,90.432,106.124,89.543,94.186,85.756,89.516,88.9,111.277,77.107,159.116,58.65],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"4df6eb45193bcf8a0edcab57b140e5e711a42313537c1b06ad7ac40495a6ed7d","reconnectToLiveMs":40.11,"canonicalVerifierHash":"dac23c9b615ba736f667822e98cd81583d5596d3eb635400fd7923a4b821f2fd","roundTripHash":"5f2b2d6429deabacf2274aa4cd918f601a4d7979d4e6425eac0dabff353df84e","remoteConvergenceMs":8.24,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":39,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"7b901f84dd62107f244be49a92208d42d8c9d3da353225163c2eae58533cc5e7","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[75.676,179.593,67.112,54.792,53.356,50.804,54.436,53.396,51.603,53.341,54.106,56.779,56.164,56.872,70.128,60.498,71.721,73.438,50.697,49.613],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"3a0e6a8d108e4259c6dacb8b38f8b350945dc7fa4e9364e3913d2caddffe18bd","reconnectToLiveMs":79.274,"canonicalVerifierHash":"1be630ac18ad34887058f68f246be9e0f8a79ae0b431e0342f77f0aafbbf60a6","roundTripHash":"010a6713bd4cb32e2f00459f2d042fbfe23920beb3dba219f9a975fd66606098","remoteConvergenceMs":7.995,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":40,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"cd40e9a07822696a4198119192a3961886f52674873dbed1fab915a6e50927d3","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[58.658,47.189,49.207,49.781,50.882,50.83,52.179,51.635,53.33,52.854,59.303,55.228,58.04,58.163,69.229,60.215,74.177,76.348,53.142,53.245],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"a91db8c869cb35510ed31cccd264a1874ddc908ddfb04db8e65f8148f5a91578","reconnectToLiveMs":70.271,"canonicalVerifierHash":"25122c18d674ea9d81866fefb9811438d5d0e6930edbb7b4f1bd1336987d3c84","roundTripHash":"f83c2ea15ce0f99cd935fd0aaeffe3bac0805ab6b6f1d45c6ba5e715257fbe02","remoteConvergenceMs":8.389,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":41,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"3c6017b5b1b61473fc4384d5c642f8c4f21fc498a2cba3981a4fcb04e8e8f815","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.535,50.453,52.33,52.467,50.524,51.755,53.871,53.392,52.601,55.377,56.604,58.993,58.145,61.613,72.18,60.587,72.989,75.687,52.332,49.186],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"f46fbceca36e6df65f09e8184dc171220da87ffa997e93edae4425e494a5644d","reconnectToLiveMs":86.319,"canonicalVerifierHash":"44e6edd99da52fe8e35e1513170aea2419cc5cb214cf506ff75a706440274ae3","roundTripHash":"519c1ac9267bb4369abd0b2bbc015623f2f8c3a7c58946522c36df8a6028a425","remoteConvergenceMs":8.44,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":42,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"2a3bb8f9e96bbff75da1c0bdcd21b3452ff8c8762013e2f6eca2ca641b707758","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[52.401,47.301,49.769,50.808,49.004,52.588,52.107,53.5,53.271,52.893,56.103,56.561,57.277,58.481,66.595,58.908,71.236,73.785,52.777,48.676],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"86043e9b248940531840e7cd18115beb2b0dddcf5dc9db88ab096d6ece3d17a4","reconnectToLiveMs":81.184,"canonicalVerifierHash":"94b10f4001fe9e25842dfeaa8246258d85efe73aa823a1f32a587317b43cc262","roundTripHash":"99affcac47d7e010b61d60b84890b99227a88f96e686afecc64ecc96e1a9e434","remoteConvergenceMs":8.461,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":43,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"3f8a10342efb6e53884d12c1e1425f2ec7ecdd96013bd9223c4475968eca8e3e","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[54.306,47.901,50.021,49.896,48.356,50.601,52.433,56.274,52.351,61.789,56.201,55.147,56.334,57.711,62.322,59.071,70.857,73.701,52.981,47.958],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"19c0e27e7c3cdf297feaa3b02930adcda8b97b4c388b33ea1769e9afdf522ba2","reconnectToLiveMs":31.06,"canonicalVerifierHash":"3f91d6fc35170cf6b81469cd10fdc2276151f41642c183954c905dc2fd72e913","roundTripHash":"2d5a3bcc5c59f2b66c302ee900ea418589d560867b9df6c4ad8433a1d8132683","remoteConvergenceMs":8.491,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":44,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"0eb474ca0c5f15b2c3bb2837e8ccb7540cc953610e19a90021b5d7fcc17d7e92","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.634,48.41,48.882,50.057,49.581,52.317,51.69,53.295,53.961,54.369,55.632,55.421,55.526,56.388,71.229,59.573,71.844,74.882,51.527,49.147],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"d296acd1d505cc41e1746ba8b4f51adf18849dbc0a28408dd5e3bf8e616d4672","reconnectToLiveMs":85.215,"canonicalVerifierHash":"45675064eb12cf1f1a91352bc3ec835026de943504172dfe66afe47547538963","roundTripHash":"db68375a5b6b0d90d87a15cb7be4f53702d3686f539bd3c6e4e91fe56aecf1f5","remoteConvergenceMs":10.166,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":45,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"48fb865b80110a4ec663a65f26f9bffa285ecb926bc3654aaac732c60d7b3b0a","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[58.571,54.422,54.35,57.046,56.453,57.909,55.084,57.663,54.816,60.273,55.275,58.253,61.867,57.962,70.439,61.782,72.682,75.279,67.517,50.186],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"129dec31e4e2f2823c59ae9b87fcae42d394591c61f5be778999c45da2a3e444","reconnectToLiveMs":71.228,"canonicalVerifierHash":"df97c65603a19f4c4351462abc77ddac04994d60de19108557822d5b7a571794","roundTripHash":"bd72d07f8cfa10ba473e751e8571d18cff71139e9de28b389a357005b0549039","remoteConvergenceMs":8.144,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":46,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"2547f5ef76c4962e0f352c8e03a471c880bc8ca5b6172b583e469813db94f886","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[57.297,49.602,48.728,51.819,52.78,51.647,52.095,53.299,52.682,56.697,60.222,59.316,57.049,58.813,63.12,59.483,72.977,74.498,51.085,49.826],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"50395ad41530e80d5559fc8b6af5507a8a393adec21dbaf5ef71718f035be3ef","reconnectToLiveMs":25.008,"canonicalVerifierHash":"9b4ee333fd4009d9b1edef10a8a06c285dae7b061b309176dac94fb8296f1af3","roundTripHash":"0574858639d852e4499b0d575c1ed16d92e54431275cbba318a6c50a5938a9f4","remoteConvergenceMs":8.292,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":47,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"74fe76755a9c32233697d6c69e3751a5a0429d237d306cfa1e17d9b51b4a8569","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[52.756,47.199,48.98,49.799,49.346,49.938,51.865,51.959,53.758,53.848,55.803,55.476,57.419,58.829,66.43,60.141,71.485,73.361,51.482,48.65],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"e3d92a8e2ae00349f3b3869363ede60248488f17094c62af9e2b17952177dc57","reconnectToLiveMs":48.063,"canonicalVerifierHash":"3c4caaedf4de1dfe50de5ca3aedad7fd6de3694157191abd14d816ba949321c5","roundTripHash":"f055ebed68cf1f028f3ac92152eb9804730bd979cbe5771187ea7d8a93f1b604","remoteConvergenceMs":7.988,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":48,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"34d1c2996c4711c53b654ccb9172bdd508ff7023bd0729c37305aff833a9e8c3","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[53.181,49.251,50.476,51.0,49.904,51.704,57.809,52.343,53.128,53.65,53.441,54.9,58.393,57.382,72.549,64.743,84.652,75.932,54.646,48.87],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"e7e8f860ed711b1ba4c898d3c6a3a7443cdcb343f3b965d64050f635bebb045a","reconnectToLiveMs":91.238,"canonicalVerifierHash":"882b979772b174022efc16c448d930c4c985077f663f74e281e64d2730ca2d9e","roundTripHash":"d800ff5ea7d263d57c07e1a252a14b4b8787f97831d2de8d8608dfa3a4187f23","remoteConvergenceMs":8.934,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":49,"adapter":"convex_flutter","operationCount":1000,"traceSha256":"cd10c09eb29b4c21d63fff22d70e92b231eb178d82ab60ea4f2dd0b671e6f0c5","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[52.343,46.916,51.398,51.697,49.817,49.45,51.962,52.53,54.376,54.323,55.072,54.702,56.073,56.094,73.688,58.779,71.976,73.063,52.839,49.83],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"8d2697434c6eed85928903a55f2524696d540ea4d20637d254aaa9ff692314fc","reconnectToLiveMs":100.349,"canonicalVerifierHash":"897c1e45d48565825398a418ad07d9557552005e11b69982d8979da0e6def54e","roundTripHash":"a93b4e6243f97268681c90698b8754de9e2cf2decf12fb6104cb376aa9ace707","remoteConvergenceMs":7.988,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10}]} \ No newline at end of file diff --git a/tool/convex_client_gauntlet/runtime/results/dartvex_correctness.json b/tool/convex_client_gauntlet/runtime/results/dartvex_correctness.json new file mode 100644 index 00000000..ec4b095f --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/results/dartvex_correctness.json @@ -0,0 +1 @@ +{"schemaVersion":1,"status":"passed","adapter":"dartvex","candidateVersion":"0.2.0","flutterRustBridgeVersion":null,"deployment":"local:127.0.0.1:3210","gitCommit":"fb83488c0924f8daf57c4bdfc48d7a4a5ff0c8f5","baseFixture":{"path":"test/fixtures/strategy_integrity/base-test-v43.ica","sha256":"8544873d608a0ad885b2e6042a383596a0b1dc37514034281b4e4eec6168756a"},"seedCount":50,"operationsPerSeed":1000,"totalOperations":50000,"allCanonicalEqual":true,"allResolved":true,"processRestartCheckpoint":true,"bytesSent":20224647,"bytesReceived":48149313,"wallClockMs":64346.84299999999,"maxRssBytes":256311296,"machine":{"operatingSystem":"macos","operatingSystemVersion":"Version 26.5.1 (Build 25F80)","processors":8,"dartVersion":"3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on \"macos_arm64\""},"seeds":[{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":true,"exercised":true,"freshTokenAcceptedMs":10.026,"manualReconnectMs":144.884,"postReconnectAcceptedMs":155.986,"queuedBatchReplayedExactlyOnce":true,"reconnectCalled":true,"recoveryMs":156.006,"refreshSessionCalled":true,"rejectedTokenObserved":true,"tokenChanged":true},"batchLatencyMs":[42.608,35.148,34.734,35.035,34.922,36.206,35.541,37.066,37.501,36.019,65.184,38.437,38.517,38.294,55.611,38.978,48.253,48.317,32.231,30.517],"canonicalVerifierHash":"26d71e8df48fba1f7aae2c8cf4b5569e8b9360166b9bb6ed887ce7f14048f2f7","duplicateNoopHash":"2f5c83b805fd22471e866932f0ad3a6b438850368d79cbaada4e91a2306d36f8","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":10,"fault":"auth_reject_refresh"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"b4e244c38b89789f1191e988768aa681f4722184533077ef58eb6f95ee6a0e52","faults":{"authRefresh":true,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":156.184,"rejected":90,"remoteConvergenceMs":9.335,"retryCount":0,"roundTripHash":"57f03a40845f1cbcec927c4a2abc68781fcec79e10d5f8a896a7deb95bbd9d69","seed":0,"traceSha256":"ddf6d41ed9ccdbf3c60766fe6b0318218dd8954d8615fcffb2a8daefe849aa06","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[39.665,34.943,35.239,36.301,36.401,38.339,36.565,39.271,37.606,38.63,38.333,39.363,38.869,39.184,52.962,41.251,48.59,48.749,32.402,31.57],"canonicalVerifierHash":"521b140c0a612b8ea44a1d9b4b03cbf0573799ae00bf05498122bacab81c1bba","duplicateNoopHash":"f5cf17c272add3c1c7d3460a3f29c4ee4bf786dd0d24d00d5010fd3069450206","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":103.463,"rejected":90,"remoteConvergenceMs":8.175,"retryCount":0,"roundTripHash":"42034206a0ce276524b5f6c4b61270d036eafa3ea888f6af32fcc8898136425d","seed":1,"traceSha256":"74e1c135f93f0e9ebf7029a5222aaa38b6aa0f834e31861e9e3de8515922bca4","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[39.39,34.965,37.174,35.563,36.11,36.582,38.492,37.793,38.708,38.19,39.021,39.703,39.282,39.583,44.904,39.624,49.498,51.921,34.448,32.191],"canonicalVerifierHash":"9ca1893e49091976796548e8568b4e1791bff1f72f5f883892ef688a59483304","duplicateNoopHash":"f241cf2843f3adfa94f6566c00df83a1f234b4993b73daa1fe08ff8b34e89260","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":55.995,"rejected":90,"remoteConvergenceMs":8.3,"retryCount":0,"roundTripHash":"44c8e21bbcb1c01d01bd1446150e3c5e3daa0f256aced3da4e96ac1afeb6be93","seed":2,"traceSha256":"2b3e81b55c925a72176cd6f3d1515c7063d7706d8d4381c641b6fc0a8e121262","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[40.069,35.791,36.04,36.85,37.931,37.629,37.651,38.799,39.617,38.815,38.987,38.969,40.033,40.719,54.526,41.157,49.59,50.288,34.368,32.436],"canonicalVerifierHash":"197d1fc1c62be082b93dd2c51021a15c68e3f7551de5e0d949b11152f6ab6840","duplicateNoopHash":"1e34802380fb4c7b66dfe194d29cf6bb4a223e342c5ac58fb77bd10b9436b658","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":119.517,"rejected":90,"remoteConvergenceMs":7.963,"retryCount":0,"roundTripHash":"18bb3a755432638399f32b7fb39627dab2c7c474726dc01b433243d45e86ab8f","seed":3,"traceSha256":"f7105a396da40cd883ec4cc9e83f1a6cb53706e90e782cc3e71cb778bf252235","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[39.892,38.059,36.454,36.528,36.675,37.514,37.357,38.272,38.264,38.817,39.921,39.005,40.313,40.61,53.866,42.152,49.182,49.745,32.958,32.346],"canonicalVerifierHash":"472d3b636388ed4b3ce98242c337cdca98191dd93feeaf23b2f0bf72c2068232","duplicateNoopHash":"94cb107907f89f5fc3a85d810566e5f67ec06498ef9345e4236bce4c79443170","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":126.523,"rejected":90,"remoteConvergenceMs":7.589,"retryCount":0,"roundTripHash":"6bc869c98139d476a3b61de2ca80637ca4d956c2e99008462e6667110516d5e8","seed":4,"traceSha256":"f8cc2d86c82f00b22442e2b457b962f2e31d958be26bd26999c5035502d44d52","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[39.953,36.043,37.474,36.596,37.1,38.671,38.786,39.016,38.579,39.851,40.928,40.816,41.237,40.89,44.574,42.409,51.474,50.047,32.932,33.015],"canonicalVerifierHash":"60e119554dd2c84670ff85ad1f4743fe3b39bb2367a81ecfa4f978cd7e834be1","duplicateNoopHash":"69a9e28c6ba4259807f9135260188fb48763db7cd60da9f0bcbb2df689ac7fec","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":54.861,"rejected":90,"remoteConvergenceMs":7.427,"retryCount":0,"roundTripHash":"3e1eb93aba491b833fe657b72a49cb08871e51b6abd93ced58a16b4c1bb1d714","seed":5,"traceSha256":"d0e377de6c9acd5bde977020ce6b5a3b79f41ba792748eeb87617f0bbfe67073","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[42.123,36.347,36.803,37.735,37.933,37.726,40.935,39.849,39.641,39.264,39.902,40.177,42.264,40.966,58.928,42.26,50.693,56.274,34.561,33.006],"canonicalVerifierHash":"870bdc3fe241a36e4cafa9cd2db6550900db537148df0012129a4fc729168e44","duplicateNoopHash":"7b6d6f868d69e5c19a34b1ef7a5da5bd85c7b2c0de6b278023971d5125376186","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":138.784,"rejected":90,"remoteConvergenceMs":7.385,"retryCount":0,"roundTripHash":"a6d78c0173ff37ca01ce546bd371b25102dff424fc71e5ed158da8cc3afb9409","seed":6,"traceSha256":"4089e29162d4a1f4eeb277698a40e77dee654c5e6b024de882412828b2b868be","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[41.37,37.751,37.476,38.235,38.176,39.042,39.029,39.761,38.91,40.261,39.847,39.827,40.132,41.345,45.8,41.917,51.416,52.069,34.72,32.827],"canonicalVerifierHash":"07d665cbffdf875c025bb158a561350943ae3fbd5068762fd11819a6a4901942","duplicateNoopHash":"abc350378349c0c5ca8ac8718147350b24bba91d01f1b3584ac0ae8728392bbb","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":58.802,"rejected":90,"remoteConvergenceMs":7.699,"retryCount":0,"roundTripHash":"e9e701572c478eb4ff07bdc25b9686439e87f671b6912b55edab9f6640bd9348","seed":7,"traceSha256":"2f479b6ed6743dedae4a36d95c58c95f225ba529fcd863e5640f49afc5e838b7","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[42.317,36.767,38.726,40.325,38.393,40.051,39.546,42.167,40.5,41.073,40.149,40.285,41.872,40.868,54.99,42.863,51.482,52.294,34.939,33.781],"canonicalVerifierHash":"efc2b0c123c804ed7f1847b3cfaec274bad1ea5ae14b606f04cc0a565458437f","duplicateNoopHash":"7c4a10f6a44922af7b90a06f9e3796d4915b37934f1958e659c352ef988032a7","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":141.639,"rejected":90,"remoteConvergenceMs":7.902,"retryCount":0,"roundTripHash":"2381d28c5762232a05a96531e03aabc0b196671f5f4c493cfb8a2bc1e16fed87","seed":8,"traceSha256":"18d04c6a59b74a9026ab9d6f69161a1ae3101de4fe16acc08b831a885a6a301f","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[42.266,37.341,38.486,40.63,38.659,39.783,39.125,41.659,40.475,42.183,41.183,41.624,43.165,43.194,48.156,43.184,52.743,52.633,36.585,32.426],"canonicalVerifierHash":"703442e3b93e7bc929da059e86ab44de647f270cefc120656f501966b6847100","duplicateNoopHash":"acb46046274da0c9262f4910b29e4fda0a2bfe3132374a35db02c9f2f89398f4","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":61.885,"rejected":90,"remoteConvergenceMs":8.022,"retryCount":0,"roundTripHash":"e63096e423e4fa5de04d55dd992a191a939e288f22077b5dba9f1f30d0312ac0","seed":9,"traceSha256":"10e7fe955f927f3626b5586eff07c5c452e7dd8fada065f6ea12839907ebfb8f","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[41.745,38.55,39.82,40.531,38.961,41.076,39.428,41.153,40.38,41.587,41.377,42.583,42.521,43.181,60.903,43.214,51.966,52.231,35.256,34.619],"canonicalVerifierHash":"0dedd28fa52610998313b57e5e621be6488408b8475b8082434152a7e4bfffde","duplicateNoopHash":"68de0d932dda40e9fa94ad87bce5062194f4b64d032743224b09543a35fadb04","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":137.639,"rejected":90,"remoteConvergenceMs":7.721,"retryCount":0,"roundTripHash":"e199bf3ee7ce8299f481c487ecb563b9bc5a22508107847ee32280f5af2799fa","seed":10,"traceSha256":"3aedbcc55f63d2e40d1053713da07189c675eeedafc45557893bfb44a4a4a3fa","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[44.99,38.3,39.078,40.912,38.761,40.816,39.776,42.124,40.569,41.758,40.988,41.695,43.061,43.269,62.173,44.115,51.016,53.068,34.685,34.861],"canonicalVerifierHash":"976df9bc169579795add78059b22aecb15add1a77fa132a762b81256d7a14ab9","duplicateNoopHash":"ecd01e95d0ba98e4c1033dd1c11ae805f94ea9c57caa7eeb3883adbdba18baa9","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":146.874,"rejected":90,"remoteConvergenceMs":7.622,"retryCount":0,"roundTripHash":"6b2a4c8cae985614914a1122c691c7976d2f5fe6bc30dca6a2a54e7a718ebbdb","seed":11,"traceSha256":"d64e19b329a28294c7e145d5eea4d659b86b00fdc478f7472e57f1b93de378ef","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[43.405,37.692,37.907,38.705,38.575,41.526,39.321,41.76,40.252,42.711,40.769,41.976,42.062,43.448,62.286,44.689,52.252,52.089,35.443,35.083],"canonicalVerifierHash":"fe27618043429656abed9f1810fd3f7fe21fa7844cfe474e4ee27170aa6ba761","duplicateNoopHash":"75cb591df89ce4872e5d71150982ccb110460036da1a031d13af69b1a497bf44","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":144.977,"rejected":90,"remoteConvergenceMs":7.633,"retryCount":0,"roundTripHash":"08db87f541a6ad80969ae614cb3c192ee4668e76d650a3c3c83ae2205d7cae2f","seed":12,"traceSha256":"56270e40851c311cafe2638b7bdf9e162ca4190285215f3a788c34ab68e9fbfa","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[44.463,38.943,41.496,39.556,39.86,41.869,40.275,43.486,40.467,42.175,40.429,43.376,43.666,43.833,59.158,45.088,53.503,51.643,35.534,35.471],"canonicalVerifierHash":"1eb6c99489819977b8c62d4ac7cc2b6332253eea89c0028a0d00f543ccc80b4e","duplicateNoopHash":"a2780357577e00a64a3d6882842034f3c7a33a7fb73f76a1bb513582adc82f48","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":117.484,"rejected":90,"remoteConvergenceMs":7.656,"retryCount":0,"roundTripHash":"30a8267dc6ce65b39cf9e2f8e9d47c425c22d34ae75df7a6427dcfbf320b49ca","seed":13,"traceSha256":"04fbda33461bbb975b9e66fc1ff0fe176931fdbd9c56fe8d3b9f9abcc939ab07","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[42.967,38.415,39.008,42.222,40.17,41.074,39.967,42.243,40.277,41.792,42.81,42.716,42.0,45.339,69.81,45.81,53.544,53.172,34.924,35.053],"canonicalVerifierHash":"9f4f93499745ce94e2ea84804d41cdd24007491f9ad333f6994c23adaca1a97f","duplicateNoopHash":"6a79d43b71c80e3ece66ab93693e6111df9ac8633c5e8af7dfbcdc86f73f16e9","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":154.188,"rejected":90,"remoteConvergenceMs":7.941,"retryCount":0,"roundTripHash":"4406199eb581980a79d880cbb483fd97b1ac2b9ecf927ac3d48a1ede8f3a8ca6","seed":14,"traceSha256":"d105bcc5f7eaa8a768bd666c3d6fb072fb97a0191a935d70e688d086e52f95c4","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[44.663,39.379,39.698,40.552,39.281,41.158,39.782,42.725,39.955,42.478,42.702,44.346,43.69,44.168,64.574,46.865,53.618,53.677,36.153,35.789],"canonicalVerifierHash":"e44921495d13f7e764fb5170d992c6806add23c1ee2a902d2ed586e78b6518f2","duplicateNoopHash":"89f952d57ff5c744f32966f233f39fdf76ff3729bca2ffb22da379572bc772cb","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":146.749,"rejected":90,"remoteConvergenceMs":7.921,"retryCount":0,"roundTripHash":"885dcea93a305a0527b65c09ab8a51e42eeab8486b0a17a812adfc8a23dd0692","seed":15,"traceSha256":"fd2509f037ad595831e5af5de358a045a4944c5070aeccceca9a85e5d93f0dcb","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[43.38,38.794,39.073,40.913,40.511,40.974,40.851,42.871,41.416,41.948,42.445,42.663,44.113,43.485,51.903,43.297,52.953,53.62,36.206,33.96],"canonicalVerifierHash":"fcab45e94cbb1dd32f40022c4f2538d8a8921f6f9a993c44da5a1af856273521","duplicateNoopHash":"1a2c13daad7b021d5bf7aba90696fb2fba2a5479ff1fa49027c88e3b5e9bf237","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":79.007,"rejected":90,"remoteConvergenceMs":7.58,"retryCount":0,"roundTripHash":"23079740e8c81056d542e80219368de85f2c9ae8740e9db95182ac2d1433a5cf","seed":16,"traceSha256":"15974b2b6d314a57e29fe393296085940f83539b4485eb9ada849149db7a99cc","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[53.581,43.208,41.272,44.059,41.826,40.468,42.165,186.644,52.932,48.933,46.565,44.732,45.943,47.413,53.895,45.598,51.81,52.493,36.281,35.466],"canonicalVerifierHash":"0ae80e9274783e32170474a495f3c31bfa702e9acbe970cb1a04c439846eb86b","duplicateNoopHash":"834e79323bda37030a4c6af90b9a0dadade53242cd9801ba090d12a86054e645","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":119.19,"rejected":90,"remoteConvergenceMs":7.705,"retryCount":0,"roundTripHash":"5ebbea09bcb5c13323f4a6dea81b146de93f7fa134b025b2163ca48c62222abc","seed":17,"traceSha256":"53cce4ffa3236c18592a997edd684f10596e8cdceacd8402d858c284a7d9054d","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[44.512,39.675,40.826,41.641,41.438,40.77,42.268,41.604,41.055,43.233,42.76,43.316,43.307,42.525,58.776,45.112,53.329,53.389,37.699,34.375],"canonicalVerifierHash":"f3fd473e5c7029efed7f8b2b39933c0240a36cf83a5eb3286adfe377c9890da5","duplicateNoopHash":"2328c71bfb35d1fc60079b4c3572d96fac5e48f92fa4fee9baa2f9a469aa7404","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":134.581,"rejected":90,"remoteConvergenceMs":7.89,"retryCount":0,"roundTripHash":"9f8e4e09c1123b52138e32282eeb53f1d13c8317d8f3b2cda0cb11c576704704","seed":18,"traceSha256":"a542c21db854c18f4cdcb385e6ac298bf7af6282c0972913cc8fce70aec86847","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[42.409,38.809,39.556,40.558,39.845,40.737,41.289,43.095,42.66,42.694,42.985,43.922,43.625,43.914,61.152,44.398,52.414,52.745,35.473,34.026],"canonicalVerifierHash":"63e156ef82374f25fc17ac38cd7fb0ffa365fa4d6fcd29f42be9a52f729ab158","duplicateNoopHash":"1121a351ad1dd9adce4685ffd6e719a32e98c70594e4a09f928d6f996e9d1207","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":138.682,"rejected":90,"remoteConvergenceMs":7.692,"retryCount":0,"roundTripHash":"3bf2bcb94fab05718ea34aed383efbf9e411b10d95a6179ae911f90d6a57cb1a","seed":19,"traceSha256":"0d92405289eea8b6bef336eab7f6bd29373c9dfcab013fe1126707d0fc4cb0a6","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[45.267,39.573,38.696,40.33,39.295,40.839,38.926,42.327,40.62,41.736,43.308,43.073,43.327,45.236,49.482,43.129,52.184,51.988,36.14,34.365],"canonicalVerifierHash":"c8bdffb3ea12e788eb2a5088294f17e8705320e3c734ca0302d660b841565e56","duplicateNoopHash":"661be1cf36a3e6dfd57dfc2f78cfaaa70d9b9adf2badc7d67b4c20ac4a528c24","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":65.988,"rejected":90,"remoteConvergenceMs":7.981,"retryCount":0,"roundTripHash":"268efccad0ace19c28dc92d9c54b320c38ac129eae3a5b5f6710d15562fa7f3a","seed":20,"traceSha256":"810f5993000813418216c7c55f6a64a15516e1b6f63e77e55dc9d0beb326a59c","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[40.99,39.316,40.747,41.417,41.389,40.996,41.297,45.814,41.508,43.889,42.97,43.136,47.003,46.298,55.177,45.932,54.098,54.685,47.453,36.333],"canonicalVerifierHash":"c26d7da5ac7f287f6edff0b7633a49ceba13692f2421bf0738cd6bb044c5428e","duplicateNoopHash":"e1ca213dd003e35e7e8cb0e94b3cc3c65a6284240156492d42496a39c0216155","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":82.961,"rejected":90,"remoteConvergenceMs":7.602,"retryCount":0,"roundTripHash":"4d284bde91877dd19f45ec5357c0bbc3213b125ea5a2ab460aac161c834e56d1","seed":21,"traceSha256":"00514f57b0533bafeeb8cef85306163b05977fd156fb8d4f6e8971f43cd5c3d9","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[58.337,40.69,39.773,41.806,40.999,39.957,41.438,43.177,42.941,43.137,42.72,43.388,43.548,50.906,64.154,48.256,61.066,60.984,42.614,35.825],"canonicalVerifierHash":"7dc98e0042f910e161dc48692e61b71bee6f5f3c4e2c185947bcd346651c62c1","duplicateNoopHash":"0c84e6481a015a570d0181acae33fe459537c904b339fab12049bfcb9f98024a","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":129.992,"rejected":90,"remoteConvergenceMs":8.282,"retryCount":0,"roundTripHash":"8af6d9079e0d0da10a62772b0ec081dc064e6480c6545292713e51b5ee9afeda","seed":22,"traceSha256":"3b219f2a009454e6a7e145c270ea4540712ac42a2e64a3de49decb95d00cb6ec","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[48.029,41.083,42.59,43.682,43.026,41.77,42.796,43.43,42.992,42.906,43.135,43.328,42.783,43.114,56.152,43.677,53.926,53.75,37.53,35.939],"canonicalVerifierHash":"1f42cdfc51ff0c4a3d156d71d7af4941eda5ef046969f200459a8dfcf0df4594","duplicateNoopHash":"4a4946b0e8eb9183e6b90e464571b002e3053e95ed6510dcb24e619868058604","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":100.084,"rejected":90,"remoteConvergenceMs":7.57,"retryCount":0,"roundTripHash":"d9eb0ae47ebe116de7ffe955c9782bbc6cf6f1bacb1ff2cb44ad3528939d70d6","seed":23,"traceSha256":"a44400c0327fe034c8e1d489d65d4a1e8444a312806565991f9f90c2e7bd2427","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[43.975,40.274,40.137,42.041,42.96,41.152,40.473,41.993,41.711,42.393,42.968,43.412,43.675,43.044,58.421,43.522,52.493,55.279,35.54,35.796],"canonicalVerifierHash":"77478c60c3ae9cd45fc8dcda6a71cd0010be62120387e64242c60b1eb7c87a4e","duplicateNoopHash":"089b4218683a43b11f6a2fb52d4b660cd1db966fe14a98a652aba04a95c23a78","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"}],"faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":false,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"finalElementCount":82,"finalLineupCount":10,"finalPageCount":2,"finalStrategyRevision":15,"operationCount":1000,"reconnectToLiveMs":116.908,"rejected":90,"remoteConvergenceMs":9.145,"retryCount":0,"roundTripHash":"0001e26052bdd0bab5724856e8de207155cb84a9d448d7aef7c173ba4e4a96e3","seed":24,"traceSha256":"dbd479d2d15a8febd16d5f86b91f7273d4c05d57076b180f1daa3d318d89b39a","unresolved":0},{"acknowledged":910,"adapter":"dartvex","auth":{"acceptedAfterRefresh":false,"exercised":false,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"queuedBatchReplayedExactlyOnce":false,"reconnectCalled":false,"recoveryMs":null,"refreshSessionCalled":false,"rejectedTokenObserved":false,"tokenChanged":false},"batchLatencyMs":[46.194,39.016,41.269,40.621,40.714,41.011,40.783,42.786,41.292,41.513,56.841,50.088,52.021,49.673,53.765,45.56,55.483,57.132,44.183,35.354],"duplicateNoopHash":"610676792608b1dd1f2e06f6dc22864d82873dd8f8cbe86ef906a8174a708d0c","faultSchedule":[{"beforeBatch":0,"fault":"offline_queue"},{"beforeBatch":3,"fault":"delay","milliseconds":25},{"afterBatch":4,"fault":"duplicate"},{"beforeBatch":7,"fault":"subscription_restart"},{"beforeBatch":14,"fault":"reconnect"},{"afterOperation":500,"fault":"process_restart"}],"faultScheduleSha256":"d680df6635276d5593cea5cf988ac54915d8810167aae2037195424564f25e44","faults":{"authRefresh":false,"boundedRetries":true,"delayedDelivery":true,"deleteRecreate":true,"duplicatedDelivery":true,"offlineQueuedEdits":true,"processRestart":true,"reconnect":true,"revisionConflict":true,"subscriptionRestart":true},"operationCount":1000,"rejected":90,"retryCount":0,"seed":25,"traceSha256":"22a209a7f9723602fadb0c3baab2d73b8a7cef893297ade4cd87a2d61122cd5b","unresolved":0,"reconnectToLiveMs":101.576,"canonicalVerifierHash":"0b7840fc3e7157c541d7862e398acaa57ac57628f918e516746483093abf6745","roundTripHash":"44ae786ea3c9385ee3d3d5321fd63b6222cca1ea07240a307559cdbbbdbecc19","remoteConvergenceMs":16.915,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":26,"adapter":"dartvex","operationCount":1000,"traceSha256":"9c530a068ba1382fbe34f6b3bd558672f8f4538963b8ad673e4be834103e93bd","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[46.234,41.63,40.936,42.48,41.607,41.334,43.58,44.372,42.697,42.725,42.979,43.908,42.339,44.072,57.92,44.638,54.499,54.336,37.77,36.422],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"119ebe1f480b6c9f5df8f95cab240265bd09233c3f4b5dcd923728ef667b7a16","reconnectToLiveMs":129.001,"canonicalVerifierHash":"b8fc7d69a834b62d4a614de02cebbde60f3a9ccb93b4bec1f27e9987beced0ce","roundTripHash":"748fe55957a8fe14588430c1b62e80c9bf9bb046656f3dcd48d1691f1b33b54b","remoteConvergenceMs":8.488,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":27,"adapter":"dartvex","operationCount":1000,"traceSha256":"0135b2eafc700e90fcbcd47b19e5a1d34ab5e99f255b4b315e2161c2896ef910","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.256,39.124,39.475,40.925,39.882,41.26,40.733,43.702,46.276,43.669,46.007,49.697,55.305,45.26,64.296,45.285,54.843,53.42,37.169,35.085],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"4ad42017a48dbc8e12d942d945792c060603e08d9b7d18872b2158673ad23d7f","reconnectToLiveMs":154.689,"canonicalVerifierHash":"16a2f4cfba0166cf6435be3b64f11efbcaa06678f991140dbacc8d4e32097661","roundTripHash":"3c1ab0d073cd7610b4cd4a7c6521c0b5e47e4534104feda430f35e148f39f367","remoteConvergenceMs":8.011,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":28,"adapter":"dartvex","operationCount":1000,"traceSha256":"03fd5bbcfd5789fb329015b4ca3015f10c5322f782f41d1a33c8739dcc250103","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.072,39.965,40.369,42.355,41.703,42.536,41.977,43.73,43.306,42.655,42.316,42.871,45.222,43.211,64.956,46.046,52.974,54.584,36.259,35.483],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"2d62e883551de7eef0494e8e419eb251320287cfd85221fa771a2ec7467bb810","reconnectToLiveMs":154.186,"canonicalVerifierHash":"b990e106461c9e852b53e3be3e6c612b61f79bacc9e5c999f8ef1de308a108a0","roundTripHash":"8231af29d3d0c7601470ec0da754f2f0fded387c6bbda084509076505efb0e9b","remoteConvergenceMs":8.059,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":29,"adapter":"dartvex","operationCount":1000,"traceSha256":"8c898c274aa218bfce9e6a4f758d9273e1401b0edeab0db3d648e233aef3c687","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.497,39.878,40.155,41.975,40.596,41.654,41.632,44.082,42.082,41.78,41.322,42.451,42.689,42.984,56.875,49.585,56.771,55.375,41.507,38.663],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"862b67674912aa8d134be72444f4cdb9a5b50c33276e2a90ef4ae87f302351a8","reconnectToLiveMs":78.069,"canonicalVerifierHash":"abd743722679bd1335b1944cd1a5e315a89097e014d9b43d57957012d766e41a","roundTripHash":"6954dfaff2a7993d6a4d77208f9bb004083a5722931341643974ed81212fa7e8","remoteConvergenceMs":8.04,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":30,"adapter":"dartvex","operationCount":1000,"traceSha256":"222c0dc904bf299d49547394de05a001ecc68710bc02a1b311b4beafb0beb019","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.681,40.799,40.974,40.953,40.552,40.607,41.857,41.8,42.629,41.38,40.858,43.434,42.728,43.527,54.171,44.274,54.212,53.196,36.127,34.308],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"d0cd4491b844b50e85c3cbec56f3095662a981c0b8b695d7c85637b0e269aad1","reconnectToLiveMs":99.276,"canonicalVerifierHash":"1894c1445629a6547704f4dfd8bb17797b825eb585c520264c5c08dbc6366d77","roundTripHash":"bb887ac1fe5cae5ba0f2625c2af2b79493cb3fd885fdd8e1560a3aebe032b712","remoteConvergenceMs":8.045,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":31,"adapter":"dartvex","operationCount":1000,"traceSha256":"c3760666249cd5b3679cb726fd60c15bde3e2a9bd079a7800fa297f824b7d339","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[43.298,38.959,38.382,41.848,42.344,40.326,40.346,44.686,42.335,42.64,41.521,41.554,44.202,43.741,51.251,44.482,54.085,54.681,39.005,35.885],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"206eaacdceff3df5532e76f3e5adc4964146e8504a64cbfddb43fabf4fce28ef","reconnectToLiveMs":75.035,"canonicalVerifierHash":"47d58b681022fd8ecc3b63238639a194262bef74422dd577da50fc761551d216","roundTripHash":"1ef7b56e658c8f1715f96246a583228c7b022d4e53b86374934a608bda3567b1","remoteConvergenceMs":7.786,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":32,"adapter":"dartvex","operationCount":1000,"traceSha256":"f8a1efc44c616e628d61be595d4b092755c5da9e4347176fbf48421b326056c1","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.09,45.687,46.955,43.621,40.107,41.113,40.637,41.248,41.798,43.484,42.107,41.891,43.468,43.699,52.351,44.981,53.788,56.743,36.665,35.184],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"9f583fc4b098f02824bb5fa50c8ed9a76709c8850a94de973e02fe98003010ee","reconnectToLiveMs":75.109,"canonicalVerifierHash":"ef6d53a40b328a9729ca3c0157aa0adbe1c7cb390ab38e0c6949e03aa9a45147","roundTripHash":"b5389d9f11296e131badd2a6f0a299b125227ebc6b542936d07d8d3fe2b0c7d0","remoteConvergenceMs":7.737,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":33,"adapter":"dartvex","operationCount":1000,"traceSha256":"53b09101de072e7f39e1f605b4e64c528f99951f9ec10abc33901a08def7d6ad","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.435,39.798,41.407,47.951,45.777,40.732,43.537,44.461,43.111,43.657,43.306,43.956,43.872,43.327,56.297,44.881,53.069,55.16,37.253,35.813],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"0c9c2a625372e9dea51750e620c118b2702bab4e7484530ad1bd6872097db785","reconnectToLiveMs":104.226,"canonicalVerifierHash":"495cf6e87b63acba4ec386a2f17165d796435465d45f391c4cf212781f7eca5b","roundTripHash":"7a43f69fdf62c9f83ba733e4b8a6488902860126dc015c5037cd8ea03da60bde","remoteConvergenceMs":7.778,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":34,"adapter":"dartvex","operationCount":1000,"traceSha256":"41039af22db14b082c0f8d27d0f337109516f3c0ff5395bbdbd52c3f2a73e366","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[47.095,41.301,40.825,42.249,39.634,40.897,42.579,44.226,42.129,42.067,42.734,42.967,43.779,43.724,57.158,44.567,56.615,55.017,39.129,36.504],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"1a24ce7e3c28f5807015976bbfa3e712f6def2a8d3b8f8ed87dfb6d2903f154b","reconnectToLiveMs":106.293,"canonicalVerifierHash":"cf93ed2b3ec0f97763ae4ab9f60906018563a26a37da1abcd4ea47f5a3b27149","roundTripHash":"1fbeea46ef7e1780b7fe8db03d2cc35b54a8e469a4fd1047a159498bfb643161","remoteConvergenceMs":7.73,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":35,"adapter":"dartvex","operationCount":1000,"traceSha256":"b9275a1393e700c6b85ebf43295ee9fbf7e0215d3e64d41b229fd4d0d1d16252","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.49,49.443,46.883,47.815,40.734,46.451,47.456,44.131,42.486,46.913,43.587,45.317,43.465,47.334,58.984,44.185,56.206,54.582,39.856,36.529],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"519d837de5b7f860793ea33c7269be33f2d3286b79c6e9e9507418df34faf035","reconnectToLiveMs":121.464,"canonicalVerifierHash":"887321746985c1c8bd0822300fe6373a4ef21380ef5ecc9c335f47e8cf9a8271","roundTripHash":"b9cea9c0d0df6e43effaaff688e7e8b7dcea72d7f88d91df5d70c9dcd51b3ece","remoteConvergenceMs":7.922,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":36,"adapter":"dartvex","operationCount":1000,"traceSha256":"c0c7e0207a6bcd0957b602550570e42db42800fe57c66626010f25242d5f8208","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.719,42.506,41.162,39.761,39.392,42.228,40.406,41.543,40.291,42.503,43.617,42.669,42.278,45.436,58.196,45.858,56.098,53.851,39.156,36.764],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"44641ee26407719b887dc44016ae6afe719ff28a61bf2ac2adff13418a002f2d","reconnectToLiveMs":109.798,"canonicalVerifierHash":"b25494e6ef17124b508fe8d47b13d055664336898cf2192f495b01b2037d7c0e","roundTripHash":"d14c83263732d3d4ce5c1ddffa521f4a8e139a0115a0b13204c75c5d5ded723f","remoteConvergenceMs":7.631,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":37,"adapter":"dartvex","operationCount":1000,"traceSha256":"78c97698051da2fb983461e5c8f6d2d3bd357afb10e6d42cf9918b98c8804b5a","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.166,41.817,41.982,42.094,39.899,42.726,40.359,43.87,42.42,41.931,42.577,43.107,44.635,45.323,54.738,45.525,56.661,55.694,36.087,35.17],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"36b5339d6a5239af0c2e64c5a19e64b084ad9ce6a27851969945465a67dbb365","reconnectToLiveMs":83.358,"canonicalVerifierHash":"c9bd2baab0265dc7a190e0cd455f13e2575f7a04ebe2a01afecfb5d78ea1cc65","roundTripHash":"6ddbcafed928297a094e02cca9d9201fd12c225dd55b7df8a267aadd5e671715","remoteConvergenceMs":7.633,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":38,"adapter":"dartvex","operationCount":1000,"traceSha256":"3f2dafd0bc96122b9fd4274b15b67a6834f00aefcce0e9fd414dc56bee7ee67b","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.932,40.228,40.432,41.801,41.63,42.562,42.729,49.707,51.857,43.805,43.119,42.909,43.017,44.312,55.097,43.811,55.0,54.319,38.159,35.281],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"4df6eb45193bcf8a0edcab57b140e5e711a42313537c1b06ad7ac40495a6ed7d","reconnectToLiveMs":91.093,"canonicalVerifierHash":"dac23c9b615ba736f667822e98cd81583d5596d3eb635400fd7923a4b821f2fd","roundTripHash":"5f2b2d6429deabacf2274aa4cd918f601a4d7979d4e6425eac0dabff353df84e","remoteConvergenceMs":7.942,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":39,"adapter":"dartvex","operationCount":1000,"traceSha256":"7b901f84dd62107f244be49a92208d42d8c9d3da353225163c2eae58533cc5e7","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.697,39.106,41.403,42.077,41.539,42.12,42.391,43.699,42.01,62.751,42.841,43.335,42.253,43.608,61.863,46.594,55.329,53.858,36.831,36.038],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"3a0e6a8d108e4259c6dacb8b38f8b350945dc7fa4e9364e3913d2caddffe18bd","reconnectToLiveMs":135.649,"canonicalVerifierHash":"1be630ac18ad34887058f68f246be9e0f8a79ae0b431e0342f77f0aafbbf60a6","roundTripHash":"010a6713bd4cb32e2f00459f2d042fbfe23920beb3dba219f9a975fd66606098","remoteConvergenceMs":7.869,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":40,"adapter":"dartvex","operationCount":1000,"traceSha256":"cd40e9a07822696a4198119192a3961886f52674873dbed1fab915a6e50927d3","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[43.365,40.04,39.822,42.147,40.44,41.77,41.642,42.799,43.771,42.327,41.826,42.524,43.756,43.561,55.4,45.837,56.339,54.431,36.703,35.129],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"a91db8c869cb35510ed31cccd264a1874ddc908ddfb04db8e65f8148f5a91578","reconnectToLiveMs":104.052,"canonicalVerifierHash":"25122c18d674ea9d81866fefb9811438d5d0e6930edbb7b4f1bd1336987d3c84","roundTripHash":"f83c2ea15ce0f99cd935fd0aaeffe3bac0805ab6b6f1d45c6ba5e715257fbe02","remoteConvergenceMs":7.952,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":41,"adapter":"dartvex","operationCount":1000,"traceSha256":"3c6017b5b1b61473fc4384d5c642f8c4f21fc498a2cba3981a4fcb04e8e8f815","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[46.122,41.241,40.143,197.297,42.51,44.375,43.146,45.811,46.608,43.548,43.023,43.926,43.684,43.621,61.45,43.919,53.558,54.785,38.385,36.568],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"f46fbceca36e6df65f09e8184dc171220da87ffa997e93edae4425e494a5644d","reconnectToLiveMs":130.688,"canonicalVerifierHash":"44e6edd99da52fe8e35e1513170aea2419cc5cb214cf506ff75a706440274ae3","roundTripHash":"519c1ac9267bb4369abd0b2bbc015623f2f8c3a7c58946522c36df8a6028a425","remoteConvergenceMs":7.933,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":42,"adapter":"dartvex","operationCount":1000,"traceSha256":"2a3bb8f9e96bbff75da1c0bdcd21b3452ff8c8762013e2f6eca2ca641b707758","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[45.988,44.689,46.071,42.675,42.725,43.834,43.538,45.137,43.46,43.332,42.474,44.537,44.883,45.693,57.094,47.814,56.171,56.62,39.951,35.788],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"86043e9b248940531840e7cd18115beb2b0dddcf5dc9db88ab096d6ece3d17a4","reconnectToLiveMs":95.254,"canonicalVerifierHash":"94b10f4001fe9e25842dfeaa8246258d85efe73aa823a1f32a587317b43cc262","roundTripHash":"99affcac47d7e010b61d60b84890b99227a88f96e686afecc64ecc96e1a9e434","remoteConvergenceMs":7.902,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":43,"adapter":"dartvex","operationCount":1000,"traceSha256":"3f8a10342efb6e53884d12c1e1425f2ec7ecdd96013bd9223c4475968eca8e3e","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[41.953,41.659,47.41,44.927,43.958,46.428,42.175,44.625,43.337,42.318,46.276,43.325,45.741,46.238,54.809,49.586,55.04,54.784,42.1,37.152],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"19c0e27e7c3cdf297feaa3b02930adcda8b97b4c388b33ea1769e9afdf522ba2","reconnectToLiveMs":95.048,"canonicalVerifierHash":"3f91d6fc35170cf6b81469cd10fdc2276151f41642c183954c905dc2fd72e913","roundTripHash":"2d5a3bcc5c59f2b66c302ee900ea418589d560867b9df6c4ad8433a1d8132683","remoteConvergenceMs":8.229,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":44,"adapter":"dartvex","operationCount":1000,"traceSha256":"0eb474ca0c5f15b2c3bb2837e8ccb7540cc953610e19a90021b5d7fcc17d7e92","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[43.793,37.782,45.4,46.95,45.749,41.041,40.945,42.482,45.489,42.804,43.291,41.962,43.043,44.073,60.594,51.917,58.633,55.131,36.915,35.685],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"d296acd1d505cc41e1746ba8b4f51adf18849dbc0a28408dd5e3bf8e616d4672","reconnectToLiveMs":54.978,"canonicalVerifierHash":"45675064eb12cf1f1a91352bc3ec835026de943504172dfe66afe47547538963","roundTripHash":"db68375a5b6b0d90d87a15cb7be4f53702d3686f539bd3c6e4e91fe56aecf1f5","remoteConvergenceMs":7.928,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":45,"adapter":"dartvex","operationCount":1000,"traceSha256":"48fb865b80110a4ec663a65f26f9bffa285ecb926bc3654aaac732c60d7b3b0a","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[43.179,38.392,41.448,44.172,40.114,42.252,40.848,41.86,42.588,42.784,43.368,43.853,45.837,43.065,47.103,47.753,55.706,54.495,39.499,35.883],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"129dec31e4e2f2823c59ae9b87fcae42d394591c61f5be778999c45da2a3e444","reconnectToLiveMs":56.787,"canonicalVerifierHash":"df97c65603a19f4c4351462abc77ddac04994d60de19108557822d5b7a571794","roundTripHash":"bd72d07f8cfa10ba473e751e8571d18cff71139e9de28b389a357005b0549039","remoteConvergenceMs":7.867,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":46,"adapter":"dartvex","operationCount":1000,"traceSha256":"2547f5ef76c4962e0f352c8e03a471c880bc8ca5b6172b583e469813db94f886","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[44.541,39.292,41.459,42.365,40.538,41.621,42.414,42.775,42.741,42.727,42.908,43.436,45.105,43.832,63.242,45.977,54.766,56.069,38.811,35.679],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"50395ad41530e80d5559fc8b6af5507a8a393adec21dbaf5ef71718f035be3ef","reconnectToLiveMs":139.178,"canonicalVerifierHash":"9b4ee333fd4009d9b1edef10a8a06c285dae7b061b309176dac94fb8296f1af3","roundTripHash":"0574858639d852e4499b0d575c1ed16d92e54431275cbba318a6c50a5938a9f4","remoteConvergenceMs":7.791,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":47,"adapter":"dartvex","operationCount":1000,"traceSha256":"74fe76755a9c32233697d6c69e3751a5a0429d237d306cfa1e17d9b51b4a8569","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[43.563,42.675,41.15,44.006,43.016,42.782,42.187,42.73,41.725,43.513,46.287,47.532,44.004,44.117,53.092,45.778,53.654,54.411,40.029,35.629],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"e3d92a8e2ae00349f3b3869363ede60248488f17094c62af9e2b17952177dc57","reconnectToLiveMs":67.892,"canonicalVerifierHash":"3c4caaedf4de1dfe50de5ca3aedad7fd6de3694157191abd14d816ba949321c5","roundTripHash":"f055ebed68cf1f028f3ac92152eb9804730bd979cbe5771187ea7d8a93f1b604","remoteConvergenceMs":8.189,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":48,"adapter":"dartvex","operationCount":1000,"traceSha256":"34d1c2996c4711c53b654ccb9172bdd508ff7023bd0729c37305aff833a9e8c3","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[49.063,41.41,39.591,46.698,41.634,44.904,47.431,47.117,43.602,43.18,41.891,43.073,46.424,46.951,58.182,44.262,56.578,54.64,42.757,41.798],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"e7e8f860ed711b1ba4c898d3c6a3a7443cdcb343f3b965d64050f635bebb045a","reconnectToLiveMs":100.174,"canonicalVerifierHash":"882b979772b174022efc16c448d930c4c985077f663f74e281e64d2730ca2d9e","roundTripHash":"d800ff5ea7d263d57c07e1a252a14b4b8787f97831d2de8d8608dfa3a4187f23","remoteConvergenceMs":7.825,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10},{"seed":49,"adapter":"dartvex","operationCount":1000,"traceSha256":"cd10c09eb29b4c21d63fff22d70e92b231eb178d82ab60ea4f2dd0b671e6f0c5","faultScheduleSha256":"2916373f0d5b6c5ff58578c3a72a0bc436584dab36f1ce931403091816eb0c7e","faultSchedule":[{"fault":"offline_queue","beforeBatch":0},{"fault":"delay","beforeBatch":3,"milliseconds":25},{"fault":"duplicate","afterBatch":4},{"fault":"subscription_restart","beforeBatch":7},{"fault":"reconnect","beforeBatch":14}],"faults":{"offlineQueuedEdits":true,"delayedDelivery":true,"duplicatedDelivery":true,"subscriptionRestart":true,"reconnect":true,"deleteRecreate":true,"revisionConflict":true,"boundedRetries":true,"authRefresh":false,"processRestart":false},"acknowledged":910,"rejected":90,"unresolved":0,"retryCount":0,"batchLatencyMs":[57.603,50.432,57.113,49.102,55.483,48.389,46.91,47.169,48.298,45.564,45.195,49.735,49.842,63.798,73.241,50.371,59.511,60.673,43.486,36.835],"auth":{"exercised":false,"rejectedTokenObserved":false,"refreshSessionCalled":false,"tokenChanged":false,"reconnectCalled":false,"recoveryMs":null,"freshTokenAcceptedMs":null,"manualReconnectMs":null,"postReconnectAcceptedMs":null,"acceptedAfterRefresh":false,"queuedBatchReplayedExactlyOnce":false},"duplicateNoopHash":"8d2697434c6eed85928903a55f2524696d540ea4d20637d254aaa9ff692314fc","reconnectToLiveMs":136.579,"canonicalVerifierHash":"897c1e45d48565825398a418ad07d9557552005e11b69982d8979da0e6def54e","roundTripHash":"a93b4e6243f97268681c90698b8754de9e2cf2decf12fb6104cb376aa9ace707","remoteConvergenceMs":7.972,"finalStrategyRevision":15,"finalPageCount":2,"finalElementCount":82,"finalLineupCount":10}]} \ No newline at end of file diff --git a/tool/convex_client_gauntlet/runtime/results/fair_rerun_matrix.json b/tool/convex_client_gauntlet/runtime/results/fair_rerun_matrix.json new file mode 100644 index 00000000..58fa93bb --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/results/fair_rerun_matrix.json @@ -0,0 +1,111 @@ +{ + "schemaVersion": 2, + "evaluation": "convex_dart_client_fair_rerun", + "harnessCommit": "fb83488c0924f8daf57c4bdfc48d7a4a5ff0c8f5", + "deployment": "local:127.0.0.1:3210", + "baseFixture": { + "path": "test/fixtures/strategy_integrity/base-test-v43.ica", + "sha256": "8544873d608a0ad885b2e6042a383596a0b1dc37514034281b4e4eec6168756a" + }, + "tooling": { + "dartvex": { + "version": "0.2.0", + "strictGatePassed": true, + "explicitResultRenameCaught": true, + "missingReturnRejectedByIcarusWrapper": true, + "unsupportedValidatorRejectedByIcarusWrapper": true, + "deterministicGeneration": true, + "stableReturnSurfaceCompleteForRuntime": false + }, + "convex_flutter": { + "version": "3.0.1 + Icarus patch", + "convexRustVersion": "0.10.4 + Icarus patch", + "generatedContractBoundary": false, + "flutterRustBridgeVersion": "2.11.1 pinned", + "authCallbackOwnedByConvexStateMachine": true, + "realManualReconnect": true, + "vendoredPackageRequired": true, + "vendoredConvexRustRequired": true + } + }, + "correctness": { + "equivalentSeedZeroTrace": true, + "equivalentSeedZeroFaultSchedule": true, + "dartvex": { + "status": "passed", + "seedsCompleted": 50, + "operationsCompleted": 50000, + "acknowledged": 45500, + "visibleRevisionRejects": 4500, + "unresolved": 0, + "authRefreshAccepted": true, + "queuedBatchReplayedExactlyOnce": true, + "processRestartRecovered": true, + "allCanonicalVerifierHashesPassed": true, + "allIcaRoundTripsPassed": true, + "reportSha256": "df57f96fca7393df0097d70dfefcb58d6de6aeb375eba739f5ea3db69edbbd9a" + }, + "convex_flutter": { + "status": "passed", + "seedsCompleted": 50, + "operationsCompleted": 50000, + "acknowledged": 45500, + "visibleRevisionRejects": 4500, + "unresolved": 0, + "authRefreshAccepted": true, + "queuedBatchReplayedExactlyOnce": true, + "processRestartRecovered": true, + "allCanonicalVerifierHashesPassed": true, + "allIcaRoundTripsPassed": true, + "reportSha256": "72cae3ed8f244f4122de1394008587da89057e44d102c9c2968315dd951e7b3f" + } + }, + "profile": { + "status": "passed", + "buildMode": "Flutter macOS profile", + "pairedTrialsPerAdapter": 10, + "totalCandidateRuns": 20, + "orderAlternated": true, + "pairedProfileReportSha256": "b1242bbdb567751648acb3c17c2e05161eae0db2dae60488d144bcfdac8b06c2", + "dartvexMedian": { + "remoteConvergenceMs": 7.3035, + "reconnectToLiveMs": 110.8925, + "authFreshTokenAcceptedMs": 9.1435, + "authRecoveryMs": 147.8825, + "maxRssBytes": 134406144, + "averageProcessCpuPercent": 9.248290046558877, + "transferredBytes": 1439001, + "runnerWallClockMs": 2749.5435 + }, + "convexFlutterMedian": { + "remoteConvergenceMs": 7.459, + "reconnectToLiveMs": 73.2425, + "authFreshTokenAcceptedMs": 127.134, + "authRecoveryMs": 205.734, + "maxRssBytes": 141312000, + "averageProcessCpuPercent": 14.533420361788625, + "transferredBytes": 1652716, + "runnerWallClockMs": 1873.387 + }, + "tradeoff": { + "convexFlutterRunnerWallClockFasterPercent": 31.865526040959157, + "convexFlutterReconnectFasterPercent": 33.95180016682823, + "convexFlutterRssHigherPercent": 5.138050831961971, + "convexFlutterCpuHigherPercent": 57.147108153212066, + "convexFlutterTransferHigherPercent": 14.851622757732619 + }, + "desktopTargetsMeasured": ["macos"], + "desktopTargetsNotMeasuredOnThisHost": ["windows", "linux"] + }, + "verdict": { + "correctnessWinner": null, + "correctnessResult": "tie", + "performanceWinner": null, + "performanceResult": "convex_flutter is faster in wall time and reconnect; Dartvex uses less CPU, RSS, and transfer", + "adoptionDecision": "keep_convex_flutter", + "applicationClientChanged": false, + "applicationDependencyPatched": true, + "reason": "Both clients are correct. Migrating to Dartvex is not justified while its stable generated runtime return surface is incomplete; convex_flutter remains the current client but now carries a local package and Rust maintenance burden.", + "nextStep": "Upstream the convex_flutter and convex-rs fixes, and separately complete Dartvex's generated return surface before reconsidering a migration." + } +} diff --git a/tool/convex_client_gauntlet/runtime/results/paired_profile_macos.json b/tool/convex_client_gauntlet/runtime/results/paired_profile_macos.json new file mode 100644 index 00000000..e672672b --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/results/paired_profile_macos.json @@ -0,0 +1,838 @@ +{ + "schemaVersion": 1, + "status": "passed", + "gitCommit": "fb83488c0924f8daf57c4bdfc48d7a4a5ff0c8f5", + "deployment": "local:127.0.0.1:3210", + "trialCountPerAdapter": 10, + "pairing": "alternating first position by trial", + "profileMode": "Flutter macOS profile build", + "cpuDefinition": "(process user seconds + system seconds) / runner wall-clock seconds", + "transferDefinition": "application JSON bytes recorded by the neutral transport; excludes WebSocket framing and protocol metadata", + "percentileDefinition": "nearest-rank p95", + "machine": { + "operatingSystem": "macos", + "operatingSystemVersion": "Version 26.5.1 (Build 25F80)", + "processors": 8, + "dartVersion": "3.11.0 (stable) (Mon Feb 9 00:38:07 2026 -0800) on \"macos_arm64\"" + }, + "toolVersions": { + "flutter": "3.41.1 stable", + "dart": "3.11.0 macos_arm64", + "rustc": "1.90.0 stable", + "node": "23.11.0", + "convexCli": "1.45.0", + "dartvex": "0.2.0", + "convexFlutter": "3.0.1 + Icarus patch", + "convexRust": "0.10.4 + Icarus patch", + "flutterRustBridge": "2.11.1 pinned" + }, + "desktopTargets": { + "packageSupported": [ + "macos", + "windows", + "linux" + ], + "measured": [ + "macos (universal arm64 + x86_64 build, arm64 host run)" + ], + "notMeasured": [ + "windows (requires Windows host)", + "linux (requires Linux host)" + ] + }, + "buildSize": { + "sharedHarnessBundleBytes": 78540800, + "convexFlutterNativeFrameworkExecutableBytes": 23195280, + "sharedAppFrameworkExecutableBytes": 8000528, + "candidateIsolatedBundleBytes": null, + "note": "The same harness bundle contains both adapters; only the native convex_flutter framework is candidate-specific." + }, + "summary": { + "dartvex": { + "remoteConvergenceMs": { + "median": 7.3035, + "p95": 7.804, + "min": 7.138, + "max": 7.804, + "values": [ + 7.318, + 7.39, + 7.451, + 7.804, + 7.369, + 7.242, + 7.289, + 7.264, + 7.246, + 7.138 + ] + }, + "reconnectToLiveMs": { + "median": 110.8925, + "p95": 150.191, + "min": 60.14, + "max": 150.191, + "values": [ + 128.669, + 71.132, + 69.177, + 150.191, + 60.14, + 98.294, + 111.336, + 110.449, + 140.548, + 142.599 + ] + }, + "authFreshTokenAcceptedMs": { + "median": 9.1435, + "p95": 9.662, + "min": 2.478, + "max": 9.662, + "values": [ + 8.819, + 8.411, + 8.754, + 2.478, + 9.241, + 9.662, + 9.36, + 9.166, + 9.121, + 9.493 + ] + }, + "authRecoveryMs": { + "median": 147.8825, + "p95": 166.973, + "min": 67.838, + "max": 166.973, + "values": [ + 138.399, + 153.409, + 153.956, + 111.622, + 156.27, + 67.838, + 154.531, + 93.486, + 142.356, + 166.973 + ] + }, + "maxRssBytes": { + "median": 134406144.0, + "p95": 134856704.0, + "min": 134201344.0, + "max": 134856704.0, + "values": [ + 134856704.0, + 134250496.0, + 134774784.0, + 134758400.0, + 134332416.0, + 134381568.0, + 134250496.0, + 134594560.0, + 134430720.0, + 134201344.0 + ] + }, + "averageProcessCpuPercent": { + "median": 9.248290046558877, + "p95": 10.118065684863536, + "min": 7.361192206911925, + "max": 10.118065684863536, + "values": [ + 8.009492732126965, + 10.118065684863536, + 9.30226673914993, + 7.361192206911925, + 9.352467522657253, + 9.598999861006481, + 9.661951505505959, + 8.087238660882674, + 7.822167239740701, + 9.194313353967823 + ] + }, + "bytesSent": { + "median": 420054.0, + "p95": 420054.0, + "min": 420054.0, + "max": 420054.0, + "values": [ + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0 + ] + }, + "bytesReceived": { + "median": 1018947.0, + "p95": 1018947.0, + "min": 1018947.0, + "max": 1018947.0, + "values": [ + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0, + 1018947.0 + ] + }, + "transferredBytes": { + "median": 1439001.0, + "p95": 1439001.0, + "min": 1439001.0, + "max": 1439001.0, + "values": [ + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0, + 1439001.0 + ] + }, + "runnerWallClockMs": { + "median": 2749.5434999999998, + "p95": 3396.189, + "min": 2470.828, + "max": 3396.189, + "values": [ + 3371.0, + 2470.828, + 2687.517, + 3396.189, + 2780.015, + 2604.438, + 2587.469, + 3091.29, + 3323.887, + 2719.072 + ] + } + }, + "convex_flutter": { + "remoteConvergenceMs": { + "median": 7.459, + "p95": 7.575, + "min": 7.294, + "max": 7.575, + "values": [ + 7.471, + 7.348, + 7.457, + 7.461, + 7.405, + 7.294, + 7.473, + 7.575, + 7.509, + 7.436 + ] + }, + "reconnectToLiveMs": { + "median": 73.2425, + "p95": 94.765, + "min": 20.316, + "max": 94.765, + "values": [ + 20.316, + 87.859, + 91.76, + 77.776, + 94.765, + 68.709, + 49.212, + 26.962, + 38.532, + 93.847 + ] + }, + "authFreshTokenAcceptedMs": { + "median": 127.134, + "p95": 171.027, + "min": 73.848, + "max": 171.027, + "values": [ + 107.752, + 73.848, + 156.794, + 170.649, + 104.023, + 122.243, + 132.025, + 89.714, + 171.027, + 150.843 + ] + }, + "authRecoveryMs": { + "median": 205.73399999999998, + "p95": 543.221, + "min": 110.845, + "max": 543.221, + "values": [ + 200.155, + 114.574, + 175.913, + 211.313, + 117.57, + 543.221, + 304.983, + 110.845, + 529.07, + 359.654 + ] + }, + "maxRssBytes": { + "median": 141312000.0, + "p95": 141541376.0, + "min": 140984320.0, + "max": 141541376.0, + "values": [ + 141393920.0, + 141328384.0, + 141279232.0, + 141295616.0, + 140984320.0, + 141279232.0, + 141541376.0, + 141197312.0, + 141475840.0, + 141492224.0 + ] + }, + "averageProcessCpuPercent": { + "median": 14.533420361788625, + "p95": 15.306079064401745, + "min": 11.674009762931128, + "max": 15.306079064401745, + "values": [ + 15.266917516661389, + 11.674009762931128, + 14.381183217380435, + 15.031850821685506, + 15.306079064401745, + 13.104043767506186, + 14.638662904530303, + 15.261467084830866, + 14.266689690152097, + 14.42817781904695 + ] + }, + "bytesSent": { + "median": 420054.0, + "p95": 420054.0, + "min": 420054.0, + "max": 420054.0, + "values": [ + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0, + 420054.0 + ] + }, + "bytesReceived": { + "median": 1232662.0, + "p95": 1351447.0, + "min": 1199927.0, + "max": 1351447.0, + "values": [ + 1199927.0, + 1351447.0, + 1228252.0, + 1265397.0, + 1228252.0, + 1237072.0, + 1199927.0, + 1228252.0, + 1277157.0, + 1237072.0 + ] + }, + "transferredBytes": { + "median": 1652716.0, + "p95": 1771501.0, + "min": 1619981.0, + "max": 1771501.0, + "values": [ + 1619981.0, + 1771501.0, + 1648306.0, + 1685451.0, + 1648306.0, + 1657126.0, + 1619981.0, + 1648306.0, + 1697211.0, + 1657126.0 + ] + }, + "runnerWallClockMs": { + "median": 1873.387, + "p95": 2312.83, + "min": 1703.637, + "max": 2312.83, + "values": [ + 1834.031, + 2312.83, + 1807.918, + 1796.186, + 1764.005, + 2136.745, + 1912.743, + 1703.637, + 2032.707, + 1940.647 + ] + } + } + }, + "samples": [ + { + "trial": 1, + "position": "first", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-01-first-dartvex.json", + "timeFile": "profile-trial-01-first-dartvex.time", + "remoteConvergenceMs": 7.318, + "reconnectToLiveMs": 128.669, + "authFreshTokenAcceptedMs": 8.819, + "authRecoveryMs": 138.399, + "maxRssBytes": 134856704, + "averageProcessCpuPercent": 8.009492732126965, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 3371.0, + "processRealSeconds": 4.34, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.07 + }, + { + "trial": 1, + "position": "second", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-01-second-convex_flutter.json", + "timeFile": "profile-trial-01-second-convex_flutter.time", + "remoteConvergenceMs": 7.471, + "reconnectToLiveMs": 20.316, + "authFreshTokenAcceptedMs": 107.752, + "authRecoveryMs": 200.155, + "maxRssBytes": 141393920, + "averageProcessCpuPercent": 15.266917516661389, + "bytesSent": 420054, + "bytesReceived": 1199927, + "transferredBytes": 1619981, + "runnerWallClockMs": 1834.031, + "processRealSeconds": 1.98, + "processUserSeconds": 0.22, + "processSystemSeconds": 0.06 + }, + { + "trial": 2, + "position": "first", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-02-first-convex_flutter.json", + "timeFile": "profile-trial-02-first-convex_flutter.time", + "remoteConvergenceMs": 7.348, + "reconnectToLiveMs": 87.859, + "authFreshTokenAcceptedMs": 73.848, + "authRecoveryMs": 114.574, + "maxRssBytes": 141328384, + "averageProcessCpuPercent": 11.674009762931128, + "bytesSent": 420054, + "bytesReceived": 1351447, + "transferredBytes": 1771501, + "runnerWallClockMs": 2312.83, + "processRealSeconds": 2.46, + "processUserSeconds": 0.22, + "processSystemSeconds": 0.05 + }, + { + "trial": 2, + "position": "second", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-02-second-dartvex.json", + "timeFile": "profile-trial-02-second-dartvex.time", + "remoteConvergenceMs": 7.39, + "reconnectToLiveMs": 71.132, + "authFreshTokenAcceptedMs": 8.411, + "authRecoveryMs": 153.409, + "maxRssBytes": 134250496, + "averageProcessCpuPercent": 10.118065684863536, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2470.828, + "processRealSeconds": 2.61, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 3, + "position": "first", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-03-first-dartvex.json", + "timeFile": "profile-trial-03-first-dartvex.time", + "remoteConvergenceMs": 7.451, + "reconnectToLiveMs": 69.177, + "authFreshTokenAcceptedMs": 8.754, + "authRecoveryMs": 153.956, + "maxRssBytes": 134774784, + "averageProcessCpuPercent": 9.30226673914993, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2687.517, + "processRealSeconds": 2.83, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 3, + "position": "second", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-03-second-convex_flutter.json", + "timeFile": "profile-trial-03-second-convex_flutter.time", + "remoteConvergenceMs": 7.457, + "reconnectToLiveMs": 91.76, + "authFreshTokenAcceptedMs": 156.794, + "authRecoveryMs": 175.913, + "maxRssBytes": 141279232, + "averageProcessCpuPercent": 14.381183217380435, + "bytesSent": 420054, + "bytesReceived": 1228252, + "transferredBytes": 1648306, + "runnerWallClockMs": 1807.918, + "processRealSeconds": 1.95, + "processUserSeconds": 0.21, + "processSystemSeconds": 0.05 + }, + { + "trial": 4, + "position": "first", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-04-first-convex_flutter.json", + "timeFile": "profile-trial-04-first-convex_flutter.time", + "remoteConvergenceMs": 7.461, + "reconnectToLiveMs": 77.776, + "authFreshTokenAcceptedMs": 170.649, + "authRecoveryMs": 211.313, + "maxRssBytes": 141295616, + "averageProcessCpuPercent": 15.031850821685506, + "bytesSent": 420054, + "bytesReceived": 1265397, + "transferredBytes": 1685451, + "runnerWallClockMs": 1796.186, + "processRealSeconds": 1.94, + "processUserSeconds": 0.22, + "processSystemSeconds": 0.05 + }, + { + "trial": 4, + "position": "second", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-04-second-dartvex.json", + "timeFile": "profile-trial-04-second-dartvex.time", + "remoteConvergenceMs": 7.804, + "reconnectToLiveMs": 150.191, + "authFreshTokenAcceptedMs": 2.478, + "authRecoveryMs": 111.622, + "maxRssBytes": 134758400, + "averageProcessCpuPercent": 7.361192206911925, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 3396.189, + "processRealSeconds": 3.54, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 5, + "position": "first", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-05-first-dartvex.json", + "timeFile": "profile-trial-05-first-dartvex.time", + "remoteConvergenceMs": 7.369, + "reconnectToLiveMs": 60.14, + "authFreshTokenAcceptedMs": 9.241, + "authRecoveryMs": 156.27, + "maxRssBytes": 134332416, + "averageProcessCpuPercent": 9.352467522657253, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2780.015, + "processRealSeconds": 2.93, + "processUserSeconds": 0.21, + "processSystemSeconds": 0.05 + }, + { + "trial": 5, + "position": "second", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-05-second-convex_flutter.json", + "timeFile": "profile-trial-05-second-convex_flutter.time", + "remoteConvergenceMs": 7.405, + "reconnectToLiveMs": 94.765, + "authFreshTokenAcceptedMs": 104.023, + "authRecoveryMs": 117.57, + "maxRssBytes": 140984320, + "averageProcessCpuPercent": 15.306079064401745, + "bytesSent": 420054, + "bytesReceived": 1228252, + "transferredBytes": 1648306, + "runnerWallClockMs": 1764.005, + "processRealSeconds": 1.91, + "processUserSeconds": 0.22, + "processSystemSeconds": 0.05 + }, + { + "trial": 6, + "position": "first", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-06-first-convex_flutter.json", + "timeFile": "profile-trial-06-first-convex_flutter.time", + "remoteConvergenceMs": 7.294, + "reconnectToLiveMs": 68.709, + "authFreshTokenAcceptedMs": 122.243, + "authRecoveryMs": 543.221, + "maxRssBytes": 141279232, + "averageProcessCpuPercent": 13.104043767506186, + "bytesSent": 420054, + "bytesReceived": 1237072, + "transferredBytes": 1657126, + "runnerWallClockMs": 2136.745, + "processRealSeconds": 2.28, + "processUserSeconds": 0.23, + "processSystemSeconds": 0.05 + }, + { + "trial": 6, + "position": "second", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-06-second-dartvex.json", + "timeFile": "profile-trial-06-second-dartvex.time", + "remoteConvergenceMs": 7.242, + "reconnectToLiveMs": 98.294, + "authFreshTokenAcceptedMs": 9.662, + "authRecoveryMs": 67.838, + "maxRssBytes": 134381568, + "averageProcessCpuPercent": 9.598999861006481, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2604.438, + "processRealSeconds": 2.75, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 7, + "position": "first", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-07-first-dartvex.json", + "timeFile": "profile-trial-07-first-dartvex.time", + "remoteConvergenceMs": 7.289, + "reconnectToLiveMs": 111.336, + "authFreshTokenAcceptedMs": 9.36, + "authRecoveryMs": 154.531, + "maxRssBytes": 134250496, + "averageProcessCpuPercent": 9.661951505505959, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2587.469, + "processRealSeconds": 2.73, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 7, + "position": "second", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-07-second-convex_flutter.json", + "timeFile": "profile-trial-07-second-convex_flutter.time", + "remoteConvergenceMs": 7.473, + "reconnectToLiveMs": 49.212, + "authFreshTokenAcceptedMs": 132.025, + "authRecoveryMs": 304.983, + "maxRssBytes": 141541376, + "averageProcessCpuPercent": 14.638662904530303, + "bytesSent": 420054, + "bytesReceived": 1199927, + "transferredBytes": 1619981, + "runnerWallClockMs": 1912.743, + "processRealSeconds": 2.05, + "processUserSeconds": 0.23, + "processSystemSeconds": 0.05 + }, + { + "trial": 8, + "position": "first", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-08-first-convex_flutter.json", + "timeFile": "profile-trial-08-first-convex_flutter.time", + "remoteConvergenceMs": 7.575, + "reconnectToLiveMs": 26.962, + "authFreshTokenAcceptedMs": 89.714, + "authRecoveryMs": 110.845, + "maxRssBytes": 141197312, + "averageProcessCpuPercent": 15.261467084830866, + "bytesSent": 420054, + "bytesReceived": 1228252, + "transferredBytes": 1648306, + "runnerWallClockMs": 1703.637, + "processRealSeconds": 1.85, + "processUserSeconds": 0.21, + "processSystemSeconds": 0.05 + }, + { + "trial": 8, + "position": "second", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-08-second-dartvex.json", + "timeFile": "profile-trial-08-second-dartvex.time", + "remoteConvergenceMs": 7.264, + "reconnectToLiveMs": 110.449, + "authFreshTokenAcceptedMs": 9.166, + "authRecoveryMs": 93.486, + "maxRssBytes": 134594560, + "averageProcessCpuPercent": 8.087238660882674, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 3091.29, + "processRealSeconds": 3.24, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + }, + { + "trial": 9, + "position": "first", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-09-first-dartvex.json", + "timeFile": "profile-trial-09-first-dartvex.time", + "remoteConvergenceMs": 7.246, + "reconnectToLiveMs": 140.548, + "authFreshTokenAcceptedMs": 9.121, + "authRecoveryMs": 142.356, + "maxRssBytes": 134430720, + "averageProcessCpuPercent": 7.822167239740701, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 3323.887, + "processRealSeconds": 3.47, + "processUserSeconds": 0.21, + "processSystemSeconds": 0.05 + }, + { + "trial": 9, + "position": "second", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-09-second-convex_flutter.json", + "timeFile": "profile-trial-09-second-convex_flutter.time", + "remoteConvergenceMs": 7.509, + "reconnectToLiveMs": 38.532, + "authFreshTokenAcceptedMs": 171.027, + "authRecoveryMs": 529.07, + "maxRssBytes": 141475840, + "averageProcessCpuPercent": 14.266689690152097, + "bytesSent": 420054, + "bytesReceived": 1277157, + "transferredBytes": 1697211, + "runnerWallClockMs": 2032.707, + "processRealSeconds": 2.17, + "processUserSeconds": 0.23, + "processSystemSeconds": 0.06 + }, + { + "trial": 10, + "position": "first", + "adapter": "convex_flutter", + "status": "passed", + "reportFile": "profile-trial-10-first-convex_flutter.json", + "timeFile": "profile-trial-10-first-convex_flutter.time", + "remoteConvergenceMs": 7.436, + "reconnectToLiveMs": 93.847, + "authFreshTokenAcceptedMs": 150.843, + "authRecoveryMs": 359.654, + "maxRssBytes": 141492224, + "averageProcessCpuPercent": 14.42817781904695, + "bytesSent": 420054, + "bytesReceived": 1237072, + "transferredBytes": 1657126, + "runnerWallClockMs": 1940.647, + "processRealSeconds": 2.08, + "processUserSeconds": 0.23, + "processSystemSeconds": 0.05 + }, + { + "trial": 10, + "position": "second", + "adapter": "dartvex", + "status": "passed", + "reportFile": "profile-trial-10-second-dartvex.json", + "timeFile": "profile-trial-10-second-dartvex.time", + "remoteConvergenceMs": 7.138, + "reconnectToLiveMs": 142.599, + "authFreshTokenAcceptedMs": 9.493, + "authRecoveryMs": 166.973, + "maxRssBytes": 134201344, + "averageProcessCpuPercent": 9.194313353967823, + "bytesSent": 420054, + "bytesReceived": 1018947, + "transferredBytes": 1439001, + "runnerWallClockMs": 2719.072, + "processRealSeconds": 2.86, + "processUserSeconds": 0.2, + "processSystemSeconds": 0.05 + } + ] +} diff --git a/tool/convex_client_gauntlet/runtime/test/transport_smoke_test.dart b/tool/convex_client_gauntlet/runtime/test/transport_smoke_test.dart new file mode 100644 index 00000000..23ce8728 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/test/transport_smoke_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus_convex_runtime_gauntlet/transport.dart'; + +const deploymentUrl = String.fromEnvironment('CONVEX_URL'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'both transports reach the same deployment', + () async { + final dartvex = DartvexTransport(deploymentUrl); + final dartvexResult = await dartvex.query('users:me', const {}); + expect(dartvexResult, isNull); + await dartvex.close(); + + final convexFlutter = await ConvexFlutterTransport.create(deploymentUrl); + final convexFlutterResult = await convexFlutter.query( + 'users:me', + const {}, + ); + expect(convexFlutterResult, isNull); + await convexFlutter.close(); + }, + skip: deploymentUrl.isEmpty ? 'Pass --dart-define=CONVEX_URL' : false, + ); +} diff --git a/tool/convex_client_gauntlet/runtime/test/workload_test.dart b/tool/convex_client_gauntlet/runtime/test/workload_test.dart new file mode 100644 index 00000000..ddef635f --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/test/workload_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:icarus_convex_runtime_gauntlet/runner.dart'; +import 'package:icarus_convex_runtime_gauntlet/workload.dart'; + +void main() { + test('each seed has one deterministic 1,000-op trace', () { + for (var seed = 0; seed < 50; seed += 1) { + final first = buildOperationTrace(seed); + final second = buildOperationTrace(seed); + expect(first, hasLength(operationsPerSeed)); + expect(canonicalHash(first), canonicalHash(second)); + expect( + first.map((operation) => operation['opId']).toSet(), + hasLength(operationsPerSeed), + ); + for (final operation in first) { + expect(operation['type'], isA()); + expect(operation, isNot(contains('kind'))); + expect(operation, isNot(contains('entityType'))); + expect(operation, isNot(contains('entityPublicId'))); + expect(operation, isNot(contains('expectedRevision'))); + } + } + }); + + test('the trace covers every synced entity boundary', () { + final trace = buildOperationTrace(0); + final types = trace.map((operation) => operation['type'] as String); + expect( + types.map((type) => type.split('.').first).toSet(), + containsAll({ + 'strategy', + 'page', + 'pageContent', + 'element', + 'lineup', + }), + ); + expect(types.where((type) => type.endsWith('.delete')), isNotEmpty); + expect(types.where((type) => type.endsWith('.reorder')), isNotEmpty); + }); + + test('canonical snapshots exclude transport clocks', () { + Object? state(double createdAt, double updatedAt) => canonicalSnapshot( + seed: 0, + snapshot: { + 'header': {'publicId': 'seed-0-strategy', 'createdAt': createdAt}, + 'pages': [ + { + 'publicId': 'seed-0-page', + 'contentCreatedAt': createdAt, + 'contentUpdatedAt': updatedAt, + }, + ], + 'elements': [], + 'lineups': [], + }, + folders: [], + ); + + expect(canonicalHash(state(1, 2)), canonicalHash(state(10, 20))); + }); + + test('rejected auth fixture is an expired, decodable JWT', () { + final parts = rejectedExpiredAccessToken.split('.'); + expect(parts, hasLength(3)); + final claims = + jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) + as Map; + expect(claims['exp'], 0); + }); +} diff --git a/tool/convex_client_gauntlet/runtime/tool/export_canonical_state.dart b/tool/convex_client_gauntlet/runtime/tool/export_canonical_state.dart new file mode 100644 index 00000000..5eaae4e4 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/tool/export_canonical_state.dart @@ -0,0 +1,49 @@ +import 'dart:io'; + +import 'package:dartvex/dartvex.dart'; +import 'package:supabase/supabase.dart'; + +import 'package:icarus_convex_runtime_gauntlet/workload.dart'; + +Future main(List arguments) async { + if (arguments.length != 1) { + stderr.writeln('Usage: dart run tool/export_canonical_state.dart '); + exitCode = 64; + return; + } + final environment = Platform.environment; + final supabase = SupabaseClient( + environment['SUPABASE_URL']!, + environment['SUPABASE_KEY']!, + authOptions: const AuthClientOptions( + authFlowType: AuthFlowType.implicit, + autoRefreshToken: false, + ), + ); + final auth = await supabase.auth.signInWithPassword( + email: environment['TEST_EMAIL']!, + password: environment['TEST_PASSWORD']!, + ); + final session = auth.session ?? (throw StateError('Sign-in failed')); + final convex = ConvexClient(environment['CONVEX_URL']!); + await convex.setAuth(session.accessToken); + try { + final snapshot = await convex.query('strategy:getFullSnapshot', { + 'clientProtocolVersion': cloudProtocolVersion, + 'strategyPublicId': strategyId(0), + }); + final folders = await convex.query('folders:listTree', {'scope': 'all'}); + final canonical = canonicalSnapshot( + seed: 0, + snapshot: snapshot, + folders: folders, + ); + await File( + arguments.single, + ).writeAsString(canonicalJson(canonical), flush: true); + stdout.writeln(canonicalHash(canonical)); + } finally { + await convex.close(); + await supabase.dispose(); + } +} diff --git a/tool/convex_client_gauntlet/runtime/tool/provision_test_account.dart b/tool/convex_client_gauntlet/runtime/tool/provision_test_account.dart new file mode 100644 index 00000000..8ba5dae0 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/tool/provision_test_account.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:supabase/supabase.dart'; + +const supabaseUrl = String.fromEnvironment('SUPABASE_URL'); +const supabaseKey = String.fromEnvironment('SUPABASE_KEY'); + +Future main(List arguments) async { + if (supabaseUrl.isEmpty || supabaseKey.isEmpty || arguments.length != 1) { + stderr.writeln( + 'Usage: dart --define=SUPABASE_URL=... --define=SUPABASE_KEY=... ' + 'run tool/provision_test_account.dart ', + ); + exitCode = 64; + return; + } + + final output = File(arguments.single); + final random = Random.secure(); + final mailboxPassword = _randomSecret(random, 32); + final accountPassword = '${_randomSecret(random, 24)}aA7!'; + final mailClient = http.Client(); + final supabase = SupabaseClient( + supabaseUrl, + supabaseKey, + authOptions: const AuthClientOptions(authFlowType: AuthFlowType.implicit), + ); + + try { + final domainResponse = await mailClient.get( + Uri.parse('https://api.mail.tm/domains?page=1'), + ); + _requireSuccess(domainResponse, 'list disposable mailbox domains'); + final domainBody = jsonDecode(domainResponse.body) as Map; + final domains = domainBody['hydra:member'] as List; + final domain = (domains.first as Map)['domain'] as String; + final address = + 'icarus-gauntlet-${DateTime.now().microsecondsSinceEpoch}@$domain'; + + final accountResponse = await mailClient.post( + Uri.parse('https://api.mail.tm/accounts'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'address': address, 'password': mailboxPassword}), + ); + _requireSuccess(accountResponse, 'create disposable mailbox'); + + final tokenResponse = await mailClient.post( + Uri.parse('https://api.mail.tm/token'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'address': address, 'password': mailboxPassword}), + ); + _requireSuccess(tokenResponse, 'authenticate disposable mailbox'); + final mailToken = + (jsonDecode(tokenResponse.body) as Map)['token'] + as String; + + await supabase.auth.signUp( + email: address, + password: accountPassword, + data: const {'display_name': 'Icarus Convex gauntlet'}, + ); + + final confirmationUrl = await _waitForConfirmation( + client: mailClient, + token: mailToken, + ); + final confirmationRequest = http.Request('GET', confirmationUrl) + ..followRedirects = false; + final confirmationResponse = await mailClient.send(confirmationRequest); + if (confirmationResponse.statusCode >= 400) { + throw StateError( + 'Supabase confirmation failed with HTTP ' + '${confirmationResponse.statusCode}', + ); + } + + final auth = await supabase.auth.signInWithPassword( + email: address, + password: accountPassword, + ); + if (auth.session == null) { + throw StateError('Confirmed test account did not produce a session'); + } + + await output.writeAsString( + jsonEncode({'email': address, 'password': accountPassword}), + flush: true, + ); + await Process.run('chmod', ['600', output.path]); + stdout.writeln('Disposable Supabase test account is confirmed and ready.'); + } finally { + mailClient.close(); + await supabase.dispose(); + } +} + +Future _waitForConfirmation({ + required http.Client client, + required String token, +}) async { + final deadline = DateTime.now().add(const Duration(minutes: 2)); + final headers = {'authorization': 'Bearer $token'}; + while (DateTime.now().isBefore(deadline)) { + final messagesResponse = await client.get( + Uri.parse('https://api.mail.tm/messages?page=1'), + headers: headers, + ); + _requireSuccess(messagesResponse, 'poll disposable mailbox'); + final body = jsonDecode(messagesResponse.body) as Map; + final messages = body['hydra:member'] as List; + if (messages.isNotEmpty) { + final id = (messages.first as Map)['id'] as String; + final messageResponse = await client.get( + Uri.parse('https://api.mail.tm/messages/$id'), + headers: headers, + ); + _requireSuccess(messageResponse, 'read confirmation message'); + final message = jsonDecode(messageResponse.body) as Map; + final source = [ + if (message['text'] is String) message['text'] as String, + if (message['html'] is List) + ...(message['html'] as List).whereType(), + ].join('\n'); + final match = RegExp( + r'''https://[^\s"'<>()\]]+/auth/v1/verify\?[^\s"'<>()\]]+''', + ).firstMatch(source); + if (match != null) { + return Uri.parse(match.group(0)!.replaceAll('&', '&')); + } + } + await Future.delayed(const Duration(seconds: 2)); + } + throw TimeoutException('Supabase confirmation email did not arrive'); +} + +String _randomSecret(Random random, int length) { + const alphabet = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return List.generate( + length, + (_) => alphabet[random.nextInt(alphabet.length)], + growable: false, + ).join(); +} + +void _requireSuccess(http.Response response, String operation) { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw StateError('$operation failed with HTTP ${response.statusCode}'); + } +} diff --git a/tool/convex_client_gauntlet/runtime/tool/run_paired_profile.sh b/tool/convex_client_gauntlet/runtime/tool/run_paired_profile.sh new file mode 100755 index 00000000..50a27d03 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/tool/run_paired_profile.sh @@ -0,0 +1,85 @@ +#!/bin/sh + +set -eu + +: "${PROFILE_OUTPUT_DIR:?Set PROFILE_OUTPUT_DIR to an empty output directory}" +: "${SUPABASE_URL:?Set SUPABASE_URL}" +: "${SUPABASE_KEY:?Set SUPABASE_KEY to the public anon key}" +: "${TEST_EMAIL:?Set TEST_EMAIL to the disposable account}" +: "${TEST_PASSWORD:?Set TEST_PASSWORD to the disposable account}" +: "${CONVEX_SELF_HOSTED_ADMIN_KEY:?Set CONVEX_SELF_HOSTED_ADMIN_KEY for the isolated deployment}" + +profile_tool_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +runtime_dir=$(CDPATH= cd -- "$profile_tool_dir/.." && pwd) +repo_dir=$(CDPATH= cd -- "$runtime_dir/../../.." && pwd) +app_dir="$runtime_dir/app" +profile_binary="$app_dir/build/macos/Build/Products/Profile/icarus_convex_runtime_runner.app/Contents/MacOS/icarus_convex_runtime_runner" +trial_count=${TRIALS:-10} +convex_url=${CONVEX_URL:-http://127.0.0.1:3210} + +if [ ! -x "$profile_binary" ]; then + printf 'Missing profile runner: %s\n' "$profile_binary" >&2 + exit 2 +fi + +mkdir -p "$PROFILE_OUTPUT_DIR" +printf 'trial\tposition\tadapter\treport\ttime\n' > "$PROFILE_OUTPUT_DIR/order.tsv" + +run_candidate() { + trial=$1 + position=$2 + adapter=$3 + report_name="profile-trial-$(printf '%02d' "$trial")-$position-$adapter" + log_file="$PROFILE_OUTPUT_DIR/$report_name.log" + time_file="$PROFILE_OUTPUT_DIR/$report_name.time" + + ( + cd "$repo_dir" + CONVEX_DEPLOYMENT= \ + CONVEX_SELF_HOSTED_URL="$convex_url" \ + npx convex import --replace-all --table users \ + "$runtime_dir/fixtures/empty.json" -y >/dev/null 2>&1 + ) + + if ! ( + cd "$app_dir" + export CONVEX_URL="$convex_url" + export ADAPTER="$adapter" + export SEED_COUNT=1 + export ALLOW_CHECKPOINT=0 + export RESET_PROGRESS=1 + export REPORT_NAME="$report_name" + export GIT_COMMIT + GIT_COMMIT=$(git -C "$repo_dir" rev-parse HEAD) + /usr/bin/time -lp "$profile_binary" > "$log_file" + ) 2> "$time_file"; then + tail -40 "$log_file" >&2 + tail -40 "$time_file" >&2 + exit 1 + fi + + report_path=$(sed -n 's/^GAUNTLET_RESULT://p' "$log_file" | tail -1 | jq -r .reportPath) + if [ ! -f "$report_path" ]; then + printf 'Missing report for trial %s, adapter %s\n' "$trial" "$adapter" >&2 + exit 1 + fi + cp "$report_path" "$PROFILE_OUTPUT_DIR/$report_name.json" + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$trial" "$position" "$adapter" "$report_name.json" "$report_name.time" \ + >> "$PROFILE_OUTPUT_DIR/order.tsv" + printf 'trial=%s position=%s adapter=%s passed\n' "$trial" "$position" "$adapter" +} + +trial=1 +while [ "$trial" -le "$trial_count" ]; do + if [ $((trial % 2)) -eq 1 ]; then + first=dartvex + second=convex_flutter + else + first=convex_flutter + second=dartvex + fi + run_candidate "$trial" first "$first" + run_candidate "$trial" second "$second" + trial=$((trial + 1)) +done diff --git a/tool/convex_client_gauntlet/runtime/tool/summarize_paired_profile.dart b/tool/convex_client_gauntlet/runtime/tool/summarize_paired_profile.dart new file mode 100644 index 00000000..f1732c81 --- /dev/null +++ b/tool/convex_client_gauntlet/runtime/tool/summarize_paired_profile.dart @@ -0,0 +1,210 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +void main(List args) { + if (args.length != 4) { + stderr.writeln( + 'Usage: dart run tool/summarize_paired_profile.dart ' + ' ' + '', + ); + exitCode = 64; + return; + } + + final rawDirectory = Directory(args[0]); + final order = File( + '${rawDirectory.path}/order.tsv', + ).readAsLinesSync().skip(1).where((line) => line.trim().isNotEmpty); + final samples = >[]; + + for (final line in order) { + final [trialText, position, adapter, reportName, timeName] = line.split( + '\t', + ); + final report = + jsonDecode(File('${rawDirectory.path}/$reportName').readAsStringSync()) + as Map; + final seed = + (report['seeds'] as List).single as Map; + final auth = seed['auth'] as Map; + final timing = _parseTime( + File('${rawDirectory.path}/$timeName').readAsStringSync(), + ); + final runnerSeconds = (report['wallClockMs'] as num).toDouble() / 1000; + final cpuPercent = + 100 * (timing.userSeconds + timing.systemSeconds) / runnerSeconds; + + samples.add({ + 'trial': int.parse(trialText), + 'position': position, + 'adapter': adapter, + 'status': report['status'], + 'reportFile': reportName, + 'timeFile': timeName, + 'remoteConvergenceMs': seed['remoteConvergenceMs'], + 'reconnectToLiveMs': seed['reconnectToLiveMs'], + 'authFreshTokenAcceptedMs': auth['freshTokenAcceptedMs'], + 'authRecoveryMs': auth['recoveryMs'], + 'maxRssBytes': timing.maxRssBytes, + 'averageProcessCpuPercent': cpuPercent, + 'bytesSent': report['bytesSent'], + 'bytesReceived': report['bytesReceived'], + 'transferredBytes': + (report['bytesSent'] as int) + (report['bytesReceived'] as int), + 'runnerWallClockMs': report['wallClockMs'], + 'processRealSeconds': timing.realSeconds, + 'processUserSeconds': timing.userSeconds, + 'processSystemSeconds': timing.systemSeconds, + }); + } + + final commits = samples + .map( + (sample) => + jsonDecode( + File( + '${rawDirectory.path}/${sample['reportFile']}', + ).readAsStringSync(), + ) + as Map, + ) + .map((report) => report['gitCommit']) + .toSet(); + final firstReport = + jsonDecode( + File( + '${rawDirectory.path}/${samples.first['reportFile']}', + ).readAsStringSync(), + ) + as Map; + + final output = { + 'schemaVersion': 1, + 'status': samples.every((sample) => sample['status'] == 'passed') + ? 'passed' + : 'failed', + 'gitCommit': commits.length == 1 ? commits.single : commits.toList(), + 'deployment': firstReport['deployment'], + 'trialCountPerAdapter': samples.length ~/ 2, + 'pairing': 'alternating first position by trial', + 'profileMode': 'Flutter macOS profile build', + 'cpuDefinition': + '(process user seconds + system seconds) / runner wall-clock seconds', + 'transferDefinition': + 'application JSON bytes recorded by the neutral transport; excludes ' + 'WebSocket framing and protocol metadata', + 'percentileDefinition': 'nearest-rank p95', + 'machine': firstReport['machine'], + 'toolVersions': { + 'flutter': '3.41.1 stable', + 'dart': '3.11.0 macos_arm64', + 'rustc': '1.90.0 stable', + 'node': '23.11.0', + 'convexCli': '1.45.0', + 'dartvex': '0.2.0', + 'convexFlutter': '3.0.1 + Icarus patch', + 'convexRust': '0.10.4 + Icarus patch', + 'flutterRustBridge': '2.11.1 pinned', + }, + 'desktopTargets': { + 'packageSupported': ['macos', 'windows', 'linux'], + 'measured': ['macos (universal arm64 + x86_64 build, arm64 host run)'], + 'notMeasured': [ + 'windows (requires Windows host)', + 'linux (requires Linux host)', + ], + }, + 'buildSize': { + 'sharedHarnessBundleBytes': int.parse(args[1]), + 'convexFlutterNativeFrameworkExecutableBytes': int.parse(args[2]), + 'sharedAppFrameworkExecutableBytes': int.parse(args[3]), + 'candidateIsolatedBundleBytes': null, + 'note': + 'The same harness bundle contains both adapters; only the native ' + 'convex_flutter framework is candidate-specific.', + }, + 'summary': { + for (final adapter in ['dartvex', 'convex_flutter']) + adapter: _summary( + samples.where((sample) => sample['adapter'] == adapter).toList(), + ), + }, + 'samples': samples, + }; + + stdout.writeln(const JsonEncoder.withIndent(' ').convert(output)); +} + +Map _summary(List> samples) { + const fields = [ + 'remoteConvergenceMs', + 'reconnectToLiveMs', + 'authFreshTokenAcceptedMs', + 'authRecoveryMs', + 'maxRssBytes', + 'averageProcessCpuPercent', + 'bytesSent', + 'bytesReceived', + 'transferredBytes', + 'runnerWallClockMs', + ]; + return { + for (final field in fields) + field: _statistics( + samples.map((sample) => (sample[field] as num).toDouble()).toList(), + ), + }; +} + +Map _statistics(List values) { + final sorted = [...values]..sort(); + final middle = sorted.length ~/ 2; + final median = sorted.length.isOdd + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; + final p95Index = math.max(0, (0.95 * sorted.length).ceil() - 1); + return { + 'median': median, + 'p95': sorted[p95Index], + 'min': sorted.first, + 'max': sorted.last, + 'values': values, + }; +} + +_ProcessTiming _parseTime(String raw) { + double value(String label) => double.parse( + RegExp( + '^$label ([0-9.]+)\\s*\$', + multiLine: true, + ).firstMatch(raw)!.group(1)!, + ); + final rss = int.parse( + RegExp( + r'^\s*([0-9]+)\s+maximum resident set size\s*$', + multiLine: true, + ).firstMatch(raw)!.group(1)!, + ); + return _ProcessTiming( + realSeconds: value('real'), + userSeconds: value('user'), + systemSeconds: value('sys'), + maxRssBytes: rss, + ); +} + +final class _ProcessTiming { + const _ProcessTiming({ + required this.realSeconds, + required this.userSeconds, + required this.systemSeconds, + required this.maxRssBytes, + }); + + final double realSeconds; + final double userSeconds; + final double systemSeconds; + final int maxRssBytes; +} diff --git a/tool/convex_client_gauntlet/test/contract_gate_test.dart b/tool/convex_client_gauntlet/test/contract_gate_test.dart new file mode 100644 index 00000000..acec78be --- /dev/null +++ b/tool/convex_client_gauntlet/test/contract_gate_test.dart @@ -0,0 +1,75 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:icarus_convex_client_gauntlet/contract_gate.dart'; +import 'package:test/test.dart'; + +void main() { + test('strict Icarus wrapper repairs the Dartvex contract gate', () async { + final result = await evaluateContractGate(); + final report = result.report; + final checks = (report['checks']! as List) + .cast>(); + + Map check(String id) => + checks.singleWhere((item) => item['id'] == id); + + expect(check('function_rename')['status'], 'pass'); + expect(check('argument_rename')['status'], 'pass'); + expect(check('result_field_rename')['status'], 'pass'); + expect(check('result_field_rename')['generatedReturnType'], 'typed'); + expect(check('result_field_rename')['analysisExitCode'], isNonZero); + expect(check('missing_return_schema')['status'], 'pass'); + expect( + check('missing_return_schema')['diagnostics'], + contains(contains('listForParent has an unexpected dynamic result')), + ); + expect(check('unsupported_validator')['status'], 'pass'); + expect(check('unsupported_validator')['rawGenerationExitCode'], 0); + expect(check('unsupported_validator')['generationExitCode'], isNonZero); + expect( + check('unsupported_validator')['diagnostics'], + contains( + contains('folders.js:listForParent → returns → field "futureField"'), + ), + ); + expect(check('deterministic_regeneration')['status'], 'pass'); + expect( + check('deterministic_regeneration')['secondSha256'], + check('deterministic_regeneration')['firstSha256'], + ); + expect(report['gatePassed'], isTrue); + expect(report['decision'], 'continue_runtime_gauntlet'); + }); + + test('result mutation changes only the returned public id field', () { + Map functionFrom(String fixtureName) { + final fixture = + jsonDecode(File('fixtures/$fixtureName').readAsStringSync()) + as Map; + return (fixture['functions']! as List).single + as Map; + } + + Map resultFields(Map function) { + final returns = function['returns']! as Map; + final item = returns['value']! as Map; + return Map.from(item['value']! as Map); + } + + final baseline = functionFrom('folders_list_for_parent.json'); + final renamed = functionFrom('folders_result_renamed.json'); + expect(renamed['identifier'], baseline['identifier']); + expect(renamed['functionType'], baseline['functionType']); + expect(renamed['visibility'], baseline['visibility']); + expect(renamed['args'], baseline['args']); + + final baselineFields = resultFields(baseline); + final renamedFields = resultFields(renamed); + expect( + renamedFields.remove('folderPublicId'), + baselineFields.remove('publicId'), + ); + expect(renamedFields, baselineFields); + }); +} diff --git a/tool/icarus_convex_codegen/README.md b/tool/icarus_convex_codegen/README.md new file mode 100644 index 00000000..fee87430 --- /dev/null +++ b/tool/icarus_convex_codegen/README.md @@ -0,0 +1,31 @@ +# Icarus Convex code generator + +This standalone tool turns the committed, deployment-scrubbed Convex function +spec into Icarus-owned Dart. It does not contact a Convex deployment. + +From the repository root: + +```sh +fvm dart run tool/icarus_convex_codegen/bin/generate.dart +``` + +The generator resolves the annotated payload codecs in +`lib/collab/convex_payload_codecs.dart`, validates every public contract node, +and replaces the complete owned output set in `lib/collab/generated/`. + +When a public backend validator changes, refresh and audit the scrubbed +snapshots before regenerating: + +```sh +npm run snapshot:convex-contract +npm run audit:convex-contract +fvm dart run tool/icarus_convex_codegen/bin/generate.dart +git diff --exit-code -- convex/function_spec.json convex/error_codes.json lib/collab/generated +``` + +CI repeats generation from the committed snapshots on Windows and Linux. Its +contract job first creates an isolated Convex preview deployment, then runs the +snapshot command in check mode against that deployment. The repository must +have a Convex preview deploy key configured as the +`CONVEX_PREVIEW_DEPLOY_KEY` Actions secret; a production or ordinary deployment +key is intentionally rejected by `--preview-create`. diff --git a/tool/icarus_convex_codegen/analysis_options.yaml b/tool/icarus_convex_codegen/analysis_options.yaml new file mode 100644 index 00000000..4d8bd09e --- /dev/null +++ b/tool/icarus_convex_codegen/analysis_options.yaml @@ -0,0 +1,3 @@ +analyzer: + errors: + avoid_print: ignore diff --git a/tool/icarus_convex_codegen/bin/generate.dart b/tool/icarus_convex_codegen/bin/generate.dart new file mode 100644 index 00000000..0f3a1521 --- /dev/null +++ b/tool/icarus_convex_codegen/bin/generate.dart @@ -0,0 +1,57 @@ +import 'dart:io'; + +import 'package:icarus_convex_codegen/icarus_convex_codegen.dart'; +import 'package:path/path.dart' as path; + +Future main(List arguments) async { + try { + final repositoryRoot = _repositoryRoot(arguments); + final contract = parseContract( + functionSpecFile: File( + path.join(repositoryRoot, 'convex', 'function_spec.json'), + ), + errorCodesFile: File( + path.join(repositoryRoot, 'convex', 'error_codes.json'), + ), + ); + final payloadBindings = await scanPayloadBindings( + File( + path.join( + repositoryRoot, + 'lib', + 'collab', + 'convex_payload_codecs.dart', + ), + ), + ); + final mapped = mapContract(contract, payloadBindings); + final output = Directory( + path.join(repositoryRoot, 'lib', 'collab', 'generated'), + ); + final files = emitContract(mapped); + writeGeneratedOutput(output, files); + stdout.writeln( + 'Generated ${files.length} files for ${contract.functions.length} ' + 'public Convex functions.', + ); + } on ContractException catch (error) { + stderr.writeln(error.message); + exitCode = 1; + } on FormatException catch (error) { + stderr.writeln(error.message); + exitCode = 1; + } +} + +String _repositoryRoot(List arguments) { + if (arguments.isNotEmpty) { + if (arguments.length != 2 || arguments.first != '--repository-root') { + throw const FormatException( + 'Usage: dart run bin/generate.dart [--repository-root PATH]', + ); + } + return path.normalize(path.absolute(arguments[1])); + } + final packageRoot = path.dirname(path.dirname(Platform.script.toFilePath())); + return path.normalize(path.join(packageRoot, '..', '..')); +} diff --git a/tool/icarus_convex_codegen/lib/icarus_convex_codegen.dart b/tool/icarus_convex_codegen/lib/icarus_convex_codegen.dart new file mode 100644 index 00000000..48191735 --- /dev/null +++ b/tool/icarus_convex_codegen/lib/icarus_convex_codegen.dart @@ -0,0 +1,7 @@ +library; + +export 'src/contract.dart'; +export 'src/emitter.dart' show emitContract, writeGeneratedOutput; +export 'src/mapper.dart' show MappedContract, mapContract; +export 'src/parser.dart' show allowedPublicIds, parseContract; +export 'src/payload_bindings.dart' show scanPayloadBindings; diff --git a/tool/icarus_convex_codegen/lib/src/contract.dart b/tool/icarus_convex_codegen/lib/src/contract.dart new file mode 100644 index 00000000..02d0a0f4 --- /dev/null +++ b/tool/icarus_convex_codegen/lib/src/contract.dart @@ -0,0 +1,119 @@ +sealed class ConvexValidator { + const ConvexValidator(); +} + +final class NullValidator extends ConvexValidator { + const NullValidator(); +} + +final class BooleanValidator extends ConvexValidator { + const BooleanValidator(); +} + +final class NumberValidator extends ConvexValidator { + const NumberValidator(); +} + +final class BigIntValidator extends ConvexValidator { + const BigIntValidator(); +} + +final class StringValidator extends ConvexValidator { + const StringValidator(); +} + +final class BytesValidator extends ConvexValidator { + const BytesValidator(); +} + +final class LiteralValidator extends ConvexValidator { + const LiteralValidator(this.value); + + final Object? value; +} + +final class IdValidator extends ConvexValidator { + const IdValidator(this.tableName); + + final String tableName; +} + +final class ArrayValidator extends ConvexValidator { + const ArrayValidator(this.item); + + final ConvexValidator item; +} + +final class ObjectField { + const ObjectField({required this.validator, required this.optional}); + + final ConvexValidator validator; + final bool optional; +} + +final class ObjectValidator extends ConvexValidator { + const ObjectValidator(this.fields); + + final Map fields; +} + +final class RecordValidator extends ConvexValidator { + const RecordValidator({required this.keys, required this.values}); + + final ConvexValidator keys; + final ConvexValidator values; +} + +final class UnionValidator extends ConvexValidator { + const UnionValidator(this.members); + + final List members; +} + +enum ConvexFunctionKind { query, mutation, action } + +final class ConvexFunctionContract { + const ConvexFunctionContract({ + required this.identifier, + required this.moduleName, + required this.functionName, + required this.kind, + required this.args, + required this.result, + }); + + final String identifier; + final String moduleName; + final String functionName; + final ConvexFunctionKind kind; + final ObjectValidator args; + final ConvexValidator result; +} + +final class ConvexContract { + const ConvexContract({required this.functions, required this.errorCodes}); + + final List functions; + final List errorCodes; +} + +final class PayloadBinding { + const PayloadBinding({ + required this.tag, + required this.codecClass, + required this.dartType, + }); + + final String tag; + final String codecClass; + final String dartType; +} + +final class ContractException implements Exception { + const ContractException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/tool/icarus_convex_codegen/lib/src/emitter.dart b/tool/icarus_convex_codegen/lib/src/emitter.dart new file mode 100644 index 00000000..ffb1c8b0 --- /dev/null +++ b/tool/icarus_convex_codegen/lib/src/emitter.dart @@ -0,0 +1,851 @@ +import 'dart:io'; + +import 'package:dart_style/dart_style.dart'; + +import 'contract.dart'; +import 'mapper.dart'; + +final _formatter = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, +); + +Map emitContract(MappedContract contract) { + final files = { + 'convex_error_codes.dart': _format(_emitErrorCodes(contract.contract)), + 'convex_models.dart': _format(_emitModels(contract)), + 'icarus_convex_api.dart': _format(_emitApi(contract)), + 'generated.dart': _format(_emitBarrel()), + }; + return Map.unmodifiable(files); +} + +void writeGeneratedOutput(Directory output, Map files) { + if (output.existsSync()) output.deleteSync(recursive: true); + output.createSync(recursive: true); + final paths = files.keys.toList()..sort(); + for (final path in paths) { + File('${output.path}/$path').writeAsStringSync(files[path]!); + } +} + +String _format(String source) => _formatter.format(source); + +String _header() => ''' +// GENERATED CODE - DO NOT MODIFY BY HAND. +// Generated from convex/function_spec.json by tool/icarus_convex_codegen. +// ignore_for_file: prefer_const_constructors, unused_element, unused_import +'''; + +String _emitBarrel() => + ''' +${_header()} +export 'convex_error_codes.dart'; +export 'convex_models.dart'; +export 'icarus_convex_api.dart'; +'''; + +String _emitErrorCodes(ConvexContract contract) { + final out = StringBuffer() + ..writeln(_header()) + ..writeln("import '../transport/convex_transport.dart';") + ..writeln() + ..writeln('enum ConvexErrorCode {'); + for (final code in contract.errorCodes) { + out.writeln(" ${lowerCamelCase(code)}('${_escape(code)}'),"); + } + out + ..writeln(" unknown('UNKNOWN');") + ..writeln() + ..writeln(' const ConvexErrorCode(this.wireName);') + ..writeln(' final String wireName;') + ..writeln() + ..writeln(' static ConvexErrorCode fromWireName(String rawCode) {') + ..writeln(' for (final code in values) {') + ..writeln( + ' if (code != unknown && code.wireName == rawCode) return code;', + ) + ..writeln(' }') + ..writeln(' return unknown;') + ..writeln(' }') + ..writeln('}') + ..writeln() + ..writeln('final class ConvexFunctionException implements Exception {') + ..writeln(' const ConvexFunctionException({') + ..writeln(' required this.code,') + ..writeln(' required this.rawCode,') + ..writeln(' required this.message,') + ..writeln(' this.data,') + ..writeln(' });') + ..writeln() + ..writeln(' factory ConvexFunctionException.fromTransport(') + ..writeln(' ConvexTransportError error,') + ..writeln(' ) => ConvexFunctionException(') + ..writeln(' code: ConvexErrorCode.fromWireName(error.rawCode),') + ..writeln(' rawCode: error.rawCode,') + ..writeln(' message: error.message,') + ..writeln(' data: error.data,') + ..writeln(' );') + ..writeln() + ..writeln(' final ConvexErrorCode code;') + ..writeln(' final String rawCode;') + ..writeln(' final String message;') + ..writeln(' final ConvexValue? data;') + ..writeln() + ..writeln(" @override String toString() => 'ConvexFunctionException('") + ..writeln(" '\$rawCode, \$message)';") + ..writeln('}'); + return out.toString(); +} + +String _emitModels(MappedContract contract) { + final out = StringBuffer() + ..writeln(_header()) + ..writeln("import 'dart:typed_data';") + ..writeln() + ..writeln("import '../convex_payload_codecs.dart';") + ..writeln("import '../transport/convex_transport.dart';") + ..writeln() + ..writeln(_modelRuntime); + + final enums = [...contract.enums]..sort((a, b) => a.name.compareTo(b.name)); + for (final declaration in enums) { + _emitEnum(out, declaration); + } + + final unions = [...contract.unions]..sort((a, b) => a.name.compareTo(b.name)); + for (final declaration in unions) { + _emitUnion(out, declaration); + } + + final models = [...contract.models]..sort((a, b) => a.name.compareTo(b.name)); + for (final declaration in models) { + _emitModel(out, declaration); + } + + final opaqueNames = contract.opaqueUnions.keys.toList()..sort(); + for (final name in opaqueNames) { + _emitOpaqueUnion(out, name, contract.opaqueUnions[name]!); + } + + final rawEmitter = _RawPredicateEmitter(); + for (final declaration in contract.rawValidators) { + rawEmitter.registerRoot(declaration.name, declaration.validator); + } + out.writeln(rawEmitter.emit()); + _emitEndpointCodecs(out, contract.functions); + return out.toString(); +} + +void _emitEndpointCodecs(StringBuffer out, List functions) { + for (final function in functions) { + final prefix = _functionPrefix(function); + out.write('ConvexObject encode${prefix}Args('); + if (function.argsFields.isNotEmpty) { + out.write('{'); + for (final field in function.argsFields) { + if (field.optional) { + out.write( + 'ConvexOptional<${field.type.dartType}> ${field.dartName} = ' + 'const ConvexOptional.absent(),', + ); + } else { + out.write('required ${field.type.dartType} ${field.dartName},'); + } + } + out.write('}'); + } + out.writeln(') => ConvexObject({'); + for (final field in function.argsFields) { + final fieldPath = + "'${_escape(function.contract.identifier)}.args.${_escape(field.wireName)}'"; + if (field.optional) { + out.writeln( + " if (${field.dartName}.isPresent) '${_escape(field.wireName)}': ${field.type.encode('${field.dartName}.value', fieldPath)},", + ); + } else { + out.writeln( + " '${_escape(field.wireName)}': ${field.type.encode(field.dartName, fieldPath)},", + ); + } + } + out + ..writeln('});') + ..writeln() + ..writeln( + '${function.resultType.dartType} decode${prefix}Result(ConvexValue value) =>', + ) + ..writeln( + ' ${function.resultType.decode('value', "'${_escape(function.contract.identifier)}.returns'")};', + ) + ..writeln(); + } +} + +void _emitEnum(StringBuffer out, EnumDeclaration declaration) { + out.writeln('enum ${declaration.name} {'); + for (final value in declaration.values) { + out.writeln(" ${value.name}('${_escape(value.wireValue)}'),"); + } + out + ..writeln(' ;') + ..writeln() + ..writeln(' const ${declaration.name}(this.wireName);') + ..writeln(' final String wireName;') + ..writeln() + ..writeln( + ' static ${declaration.name} fromWireName(String wireName, String path) {', + ) + ..writeln(' for (final value in values) {') + ..writeln(' if (value.wireName == wireName) return value;') + ..writeln(' }') + ..writeln( + " throw ConvexDecodingException(path, 'unknown ${declaration.name} \$wireName');", + ) + ..writeln(' }') + ..writeln('}') + ..writeln(); +} + +void _emitUnion(StringBuffer out, UnionDeclaration declaration) { + out + ..writeln('sealed class ${declaration.name} {') + ..writeln(' const ${declaration.name}();') + ..writeln() + ..writeln( + ' factory ${declaration.name}.decode(ConvexValue value, String path) {', + ) + ..writeln(' final object = _decodeObject(value, path);') + ..writeln(' final discriminator = _decodeString(') + ..writeln( + " object.value['${_escape(declaration.discriminatorName)}'] ?? _missing(path, '${_escape(declaration.discriminatorName)}'),", + ) + ..writeln(" '\$path.${_escape(declaration.discriminatorName)}',") + ..writeln(' );') + ..writeln(' return switch (discriminator) {'); + for (final variant in declaration.variants) { + out.writeln( + " '${_escape(variant.discriminatorValue)}' => ${variant.model.name}.decode(value, path),", + ); + } + out + ..writeln( + " _ => throw ConvexDecodingException(path, 'unknown discriminator \$discriminator'),", + ) + ..writeln(' };') + ..writeln(' }') + ..writeln() + ..writeln(' ConvexObject encode(String path);') + ..writeln('}') + ..writeln(); +} + +void _emitModel(StringBuffer out, ModelDeclaration declaration) { + final inheritance = declaration.baseName == null + ? '' + : ' extends ${declaration.baseName}'; + out + ..writeln('final class ${declaration.name}$inheritance {') + ..write(' const ${declaration.name}({'); + for (final field in declaration.fields.where((field) => !field.optional)) { + out.write('required this.${field.dartName},'); + } + for (final field in declaration.fields.where((field) => field.optional)) { + out.write('this.${field.dartName} = const ConvexOptional.absent(),'); + } + out.writeln('});'); + for (final field in declaration.fields) { + final type = field.optional + ? 'ConvexOptional<${field.type.dartType}>' + : field.type.dartType; + out.writeln(' final $type ${field.dartName};'); + } + out + ..writeln() + ..writeln( + ' factory ${declaration.name}.decode(ConvexValue value, String path) {', + ) + ..writeln(' final object = _decodeObject(value, path);') + ..writeln(' _checkObjectFields(object, path, const {'); + if (declaration.discriminatorName != null) { + out.writeln(" '${_escape(declaration.discriminatorName!)}',"); + } + for (final field in declaration.fields) { + out.writeln(" '${_escape(field.wireName)}',"); + } + out + ..writeln(' });') + ..writeln(' return ${declaration.name}('); + for (final field in declaration.fields) { + final wire = _escape(field.wireName); + final fieldPath = "'\$path.$wire'"; + if (field.optional) { + out + ..writeln( + ' ${field.dartName}: object.value.containsKey(\'$wire\')', + ) + ..writeln(' ? ConvexOptional.present(') + ..writeln( + ' ${field.type.decode("object.value['$wire']!", fieldPath)},', + ) + ..writeln(' )') + ..writeln(' : const ConvexOptional.absent(),'); + } else { + out.writeln( + ' ${field.dartName}: ${field.type.decode("object.value['$wire'] ?? _missing(path, '$wire')", fieldPath)},', + ); + } + } + out + ..writeln(' );') + ..writeln(' }') + ..writeln() + ..writeln( + declaration.baseName == null + ? ' ConvexObject encode(String path) {' + : ' @override ConvexObject encode(String path) {', + ) + ..writeln(' return ConvexObject({'); + if (declaration.discriminatorName != null) { + out.writeln( + " '${_escape(declaration.discriminatorName!)}': ConvexString('${_escape(declaration.discriminatorValue!)}'),", + ); + } + for (final field in declaration.fields) { + final wire = _escape(field.wireName); + final fieldPath = "'\$path.$wire'"; + if (field.optional) { + out.writeln( + " if (${field.dartName}.isPresent) '$wire': ${field.type.encode('${field.dartName}.value', fieldPath)},", + ); + } else { + out.writeln( + " '$wire': ${field.type.encode(field.dartName, fieldPath)},", + ); + } + } + out + ..writeln(' });') + ..writeln(' }') + ..writeln('}') + ..writeln(); +} + +void _emitOpaqueUnion( + StringBuffer out, + String name, + List bindings, +) { + final dartType = bindings.first.dartType; + out + ..writeln('$dartType _decode$name(ConvexValue value, String path) {') + ..writeln(' final object = _decodeObject(value, path);') + ..writeln(" final tag = _decodeString(object.value['kind'] ??") + ..writeln(" _missing(path, 'kind'), '\$path.kind');") + ..writeln(' return switch (tag) {'); + for (final binding in bindings) { + out.writeln( + " '${_escape(binding.tag)}' => _decodePayload(() => const ${binding.codecClass}().decode(value), path),", + ); + } + out + ..writeln( + " _ => throw ConvexDecodingException('\$path.kind', 'unknown payload tag \$tag'),", + ) + ..writeln(' };') + ..writeln('}') + ..writeln() + ..writeln('ConvexValue _encode$name($dartType value, String path) {') + ..writeln(" final tag = value['kind'];") + ..writeln(' return switch (tag) {'); + for (final binding in bindings) { + out.writeln( + " '${_escape(binding.tag)}' => _encodePayload(() => const ${binding.codecClass}().encode(value), path),", + ); + } + out + ..writeln( + " _ => throw ConvexEncodingException('\$path.kind', 'unknown payload tag \$tag'),", + ) + ..writeln(' };') + ..writeln('}') + ..writeln(); +} + +String _emitApi(MappedContract contract) { + final functionsByModule = >{}; + for (final function in contract.functions) { + functionsByModule + .putIfAbsent(function.contract.moduleName, () => []) + .add(function); + } + final moduleNames = functionsByModule.keys.toList()..sort(); + final out = StringBuffer() + ..writeln(_header()) + ..writeln("import 'dart:async';") + ..writeln("import 'dart:typed_data';") + ..writeln() + ..writeln("import '../transport/convex_transport.dart';") + ..writeln("import 'convex_error_codes.dart';") + ..writeln("import 'convex_models.dart';") + ..writeln() + ..writeln(_queryRuntime) + ..writeln('abstract interface class IcarusConvexApi {') + ..writeln( + ' factory IcarusConvexApi(ConvexTransport transport) = _IcarusConvexApi;', + ); + for (final module in moduleNames) { + out.writeln(' ${pascalCase(module)}Module get ${lowerCamelCase(module)};'); + } + out + ..writeln('}') + ..writeln() + ..writeln('final class _IcarusConvexApi implements IcarusConvexApi {') + ..writeln(' _IcarusConvexApi(ConvexTransport transport)'); + if (moduleNames.isEmpty) { + out.writeln(' ;'); + } else { + out.writeln(' :'); + for (var index = 0; index < moduleNames.length; index += 1) { + final module = moduleNames[index]; + final ending = index == moduleNames.length - 1 ? ';' : ','; + out.writeln( + ' ${lowerCamelCase(module)} = _${pascalCase(module)}Module(transport)$ending', + ); + } + } + for (final module in moduleNames) { + out + ..writeln(' @override') + ..writeln( + ' final ${pascalCase(module)}Module ${lowerCamelCase(module)};', + ); + } + out + ..writeln('}') + ..writeln(); + + for (final module in moduleNames) { + final functions = functionsByModule[module]! + ..sort( + (left, right) => + left.contract.functionName.compareTo(right.contract.functionName), + ); + _emitModule(out, module, functions); + } + return out.toString(); +} + +void _emitModule( + StringBuffer out, + String module, + List functions, +) { + final moduleType = '${pascalCase(module)}Module'; + out.writeln('abstract interface class $moduleType {'); + for (final function in functions) { + out.writeln(' ${_methodSignature(function)};'); + } + out + ..writeln('}') + ..writeln() + ..writeln('final class _$moduleType implements $moduleType {') + ..writeln(' const _$moduleType(this._transport);') + ..writeln(' final ConvexTransport _transport;'); + for (final function in functions) { + out + ..writeln(' @override') + ..writeln(' ${_methodSignature(function)} {'); + final prefix = _functionPrefix(function); + out.write(' final args = encode${prefix}Args('); + if (function.argsFields.isNotEmpty) { + for (final field in function.argsFields) { + out.write('${field.dartName}: ${field.dartName},'); + } + } + out.writeln(');'); + final route = function.contract.identifier.replaceFirst('.js:', ':'); + final decode = 'decode${prefix}Result'; + switch (function.contract.kind) { + case ConvexFunctionKind.query: + out + ..writeln(' return ConvexQuery(') + ..writeln(' transport: _transport,') + ..writeln(" name: '${_escape(route)}',") + ..writeln(' args: args,') + ..writeln(' decode: $decode,') + ..writeln(' );'); + case ConvexFunctionKind.mutation: + out.writeln( + " return _invoke(() => _transport.mutation('${_escape(route)}', args), $decode);", + ); + case ConvexFunctionKind.action: + out.writeln( + " return _invoke(() => _transport.action('${_escape(route)}', args), $decode);", + ); + } + out.writeln(' }'); + } + out + ..writeln('}') + ..writeln(); +} + +String _functionPrefix(MappedFunction function) => + '${pascalCase(function.contract.moduleName)}' + '${pascalCase(function.contract.functionName)}'; + +String _methodSignature(MappedFunction function) { + final returnType = switch (function.contract.kind) { + ConvexFunctionKind.query => 'ConvexQuery<${function.resultType.dartType}>', + ConvexFunctionKind.mutation || + ConvexFunctionKind.action => 'Future<${function.resultType.dartType}>', + }; + final parameters = StringBuffer(); + if (function.argsFields.isNotEmpty) { + parameters.write('{'); + for (final field in function.argsFields) { + if (field.optional) { + parameters.write( + 'ConvexOptional<${field.type.dartType}> ${field.dartName} = ' + 'const ConvexOptional.absent(),', + ); + } else { + parameters.write('required ${field.type.dartType} ${field.dartName},'); + } + } + parameters.write('}'); + } + return '$returnType ${lowerCamelCase(function.contract.functionName)}($parameters)'; +} + +final class _RawPredicateEmitter { + final _nameBySignature = {}; + final _validatorByName = {}; + var _next = 0; + + void registerRoot(String name, ConvexValidator validator) { + _register(validator, preferredName: name); + } + + String _register(ConvexValidator validator, {String? preferredName}) { + final signature = validatorSignature(validator); + final existing = _nameBySignature[signature]; + if (existing != null) return existing; + final name = preferredName ?? '_matchesRaw${_next++}'; + _nameBySignature[signature] = name; + _validatorByName[name] = validator; + for (final child in _children(validator)) { + _register(child); + } + return name; + } + + String emit() { + final out = StringBuffer(); + for (final entry in _validatorByName.entries) { + out + ..writeln('bool ${entry.key}(ConvexValue value) =>') + ..writeln(' ${_predicate(entry.value, 'value')};') + ..writeln(); + } + return out.toString(); + } + + String _predicate(ConvexValidator validator, String input) { + String child(ConvexValidator validator, String childValue) { + final name = _nameBySignature[validatorSignature(validator)]!; + return '$name($childValue)'; + } + + return switch (validator) { + NullValidator() => '$input is ConvexNull', + BooleanValidator() => '$input is ConvexBoolean', + NumberValidator() => '($input is ConvexFloat || $input is ConvexInteger)', + BigIntValidator() => '$input is ConvexBigInt', + StringValidator() || IdValidator() => '$input is ConvexString', + BytesValidator() => '$input is ConvexBytes', + LiteralValidator(value: final literal) => _literalPredicate( + literal, + value: input, + ), + ArrayValidator(:final item) => + '$input is ConvexArray && $input.value.every((item) => ${child(item, 'item')})', + ObjectValidator(:final fields) => _objectPredicate(fields, input, child), + RecordValidator(:final keys, :final values) => + '$input is ConvexObject && $input.value.entries.every((entry) => ' + '${child(keys, 'ConvexString(entry.key)')} && ${child(values, 'entry.value')})', + UnionValidator(:final members) => + '(${members.map((member) => child(member, input)).join(' || ')})', + }; + } + + String _literalPredicate(Object? literal, {required String value}) { + if (literal == null) return '$value is ConvexNull'; + if (literal is String) { + return "$value is ConvexString && $value.value == '${_escape(literal)}'"; + } + if (literal is bool) { + return '$value is ConvexBoolean && $value.value == $literal'; + } + if (literal is num) { + return '($value is ConvexFloat && $value.value == $literal) || ' + '($value is ConvexInteger && $value.value == $literal)'; + } + throw ContractException('Unsupported raw literal $literal'); + } + + String _objectPredicate( + Map fields, + String value, + String Function(ConvexValidator, String) child, + ) { + final allowed = fields.keys.map((key) => "'${_escape(key)}'").join(','); + final checks = [ + '$value is ConvexObject', + '$value.value.keys.every(const {$allowed}.contains)', + ]; + for (final entry in fields.entries) { + final access = "$value.value['${_escape(entry.key)}']"; + if (entry.value.optional) { + checks.add( + '($access == null || ${child(entry.value.validator, '$access!')})', + ); + } else { + checks.add( + '($access != null && ${child(entry.value.validator, '$access!')})', + ); + } + } + return checks.join(' && '); + } + + Iterable _children(ConvexValidator validator) sync* { + switch (validator) { + case ArrayValidator(:final item): + yield item; + case ObjectValidator(:final fields): + for (final field in fields.values) yield field.validator; + case RecordValidator(:final keys, :final values): + yield keys; + yield values; + case UnionValidator(:final members): + yield* members; + default: + return; + } + } +} + +String _escape(String value) => value + .replaceAll(r'\', r'\\') + .replaceAll("'", r"\'") + .replaceAll(r'$', r'\$'); + +const _modelRuntime = r''' +final class ConvexOptional { + const ConvexOptional.absent() + : isPresent = false, + _value = null; + const ConvexOptional.present(T value) + : isPresent = true, + _value = value; + + final bool isPresent; + final T? _value; + + T get value { + if (!isPresent) throw StateError('Optional value is absent'); + return _value as T; + } +} + +final class ConvexDecodingException extends FormatException { + ConvexDecodingException(this.path, String message) + : super('$path: $message'); + final String path; +} + +final class ConvexEncodingException extends FormatException { + ConvexEncodingException(this.path, String message) + : super('$path: $message'); + final String path; +} + +Never _missing(String path, String field) => + throw ConvexDecodingException('$path.$field', 'missing required field'); + +String _fieldPath(String path, String field) => '$path.$field'; + +String _indexPath(String path, int index) => '$path[$index]'; + +void _checkObjectFields( + ConvexObject object, + String path, + Set allowed, +) { + for (final field in object.value.keys) { + if (!allowed.contains(field)) { + throw ConvexDecodingException('$path.$field', 'unexpected field'); + } + } +} + +Null _decodeNull(ConvexValue value, String path) { + if (value is ConvexNull) return null; + throw ConvexDecodingException(path, 'expected null'); +} + +bool _decodeBoolean(ConvexValue value, String path) { + if (value case ConvexBoolean(:final value)) return value; + throw ConvexDecodingException(path, 'expected boolean'); +} + +double _decodeNumber(ConvexValue value, String path) { + if (value case ConvexFloat(:final value)) return value; + if (value case ConvexInteger(:final value)) return value.toDouble(); + throw ConvexDecodingException(path, 'expected number'); +} + +ConvexValue _encodeNumber(double value, String path) { + return ConvexFloat(value); +} + +BigInt _decodeBigInt(ConvexValue value, String path) { + if (value case ConvexBigInt(:final value)) return value; + throw ConvexDecodingException(path, 'expected bigint'); +} + +String _decodeString(ConvexValue value, String path) { + if (value case ConvexString(:final value)) return value; + throw ConvexDecodingException(path, 'expected string'); +} + +Uint8List _decodeBytes(ConvexValue value, String path) { + if (value case ConvexBytes(:final value)) return Uint8List.fromList(value); + throw ConvexDecodingException(path, 'expected bytes'); +} + +ConvexArray _decodeArray(ConvexValue value, String path) { + if (value is ConvexArray) return value; + throw ConvexDecodingException(path, 'expected array'); +} + +ConvexObject _decodeObject(ConvexValue value, String path) { + if (value is ConvexObject) return value; + throw ConvexDecodingException(path, 'expected object'); +} + +T _expectLiteral(T value, T expected, String path) { + if (value == expected) return value; + throw ConvexDecodingException(path, 'expected literal $expected'); +} + +T _decodePayload(T Function() decode, String path) { + try { + return decode(); + } on FormatException catch (error) { + throw ConvexDecodingException(path, error.message); + } +} + +ConvexValue _encodePayload(ConvexValue Function() encode, String path) { + try { + return encode(); + } on FormatException catch (error) { + throw ConvexEncodingException(path, error.message); + } +} + +ConvexValue _decodeRaw( + ConvexValue value, + String path, + bool Function(ConvexValue) accepts, +) { + if (accepts(value)) return value; + throw ConvexDecodingException(path, 'value does not satisfy closed union'); +} +'''; + +const _queryRuntime = r''' +final class ConvexQuery { + const ConvexQuery({ + required ConvexTransport transport, + required String name, + required ConvexObject args, + required T Function(ConvexValue) decode, + }) : _transport = transport, + _name = name, + _args = args, + _decode = decode; + + final ConvexTransport _transport; + final String _name; + final ConvexObject _args; + final T Function(ConvexValue) _decode; + + Future fetch() => _invoke( + () => _transport.query(_name, _args), + _decode, + ); + + Stream watch() { + late final StreamController controller; + StreamSubscription? subscription; + var active = false; + + controller = StreamController( + onListen: () { + active = true; + subscription = _transport.subscribe(_name, _args).listen( + (value) { + if (!active) return; + try { + controller.add(_decode(value)); + } catch (error, stackTrace) { + active = false; + controller.addError(error, stackTrace); + subscription?.cancel(); + controller.close(); + } + }, + onError: (Object error, StackTrace stackTrace) { + if (!active) return; + if (error is ConvexTransportError) { + controller.addError( + ConvexFunctionException.fromTransport(error), + stackTrace, + ); + return; + } + active = false; + controller.addError(error, stackTrace); + subscription?.cancel(); + controller.close(); + }, + onDone: () { + if (!active) return; + active = false; + controller.close(); + }, + ); + }, + onCancel: () { + active = false; + return subscription?.cancel(); + }, + ); + return controller.stream; + } +} + +Future _invoke( + Future Function() invoke, + T Function(ConvexValue) decode, +) async { + try { + return decode(await invoke()); + } on ConvexTransportError catch (error) { + throw ConvexFunctionException.fromTransport(error); + } +} +'''; diff --git a/tool/icarus_convex_codegen/lib/src/mapper.dart b/tool/icarus_convex_codegen/lib/src/mapper.dart new file mode 100644 index 00000000..141d4a75 --- /dev/null +++ b/tool/icarus_convex_codegen/lib/src/mapper.dart @@ -0,0 +1,814 @@ +import 'dart:convert'; + +import 'contract.dart'; + +final class MappedContract { + const MappedContract({ + required this.contract, + required this.functions, + required this.models, + required this.enums, + required this.unions, + required this.rawValidators, + required this.opaqueUnions, + required this.payloadBindings, + }); + + final ConvexContract contract; + final List functions; + final List models; + final List enums; + final List unions; + final List rawValidators; + final Map> opaqueUnions; + final Map payloadBindings; +} + +final class MappedFunction { + const MappedFunction({ + required this.contract, + required this.argsFields, + required this.resultType, + }); + + final ConvexFunctionContract contract; + final List argsFields; + final MappedType resultType; +} + +final class MappedField { + const MappedField({ + required this.wireName, + required this.dartName, + required this.type, + required this.optional, + }); + + final String wireName; + final String dartName; + final MappedType type; + final bool optional; +} + +final class ModelDeclaration { + const ModelDeclaration({ + required this.name, + required this.fields, + required this.signature, + this.baseName, + this.discriminatorName, + this.discriminatorValue, + }); + + final String name; + final List fields; + final String signature; + final String? baseName; + final String? discriminatorName; + final String? discriminatorValue; +} + +final class EnumValueDeclaration { + const EnumValueDeclaration({required this.name, required this.wireValue}); + + final String name; + final String wireValue; +} + +final class EnumDeclaration { + const EnumDeclaration({ + required this.name, + required this.values, + required this.signature, + }); + + final String name; + final List values; + final String signature; +} + +final class UnionVariantDeclaration { + const UnionVariantDeclaration({ + required this.discriminatorValue, + required this.model, + }); + + final String discriminatorValue; + final ModelDeclaration model; +} + +final class UnionDeclaration { + const UnionDeclaration({ + required this.name, + required this.discriminatorName, + required this.variants, + required this.signature, + }); + + final String name; + final String discriminatorName; + final List variants; + final String signature; +} + +final class RawValidatorDeclaration { + const RawValidatorDeclaration({ + required this.name, + required this.validator, + required this.signature, + }); + + final String name; + final ConvexValidator validator; + final String signature; +} + +sealed class MappedType { + const MappedType(); + + String get dartType; + + String decode(String value, String path); + + String encode(String value, String path); +} + +final class PrimitiveMappedType extends MappedType { + const PrimitiveMappedType(this.kind); + + final String kind; + + @override + String get dartType => switch (kind) { + 'null' => 'Null', + 'boolean' => 'bool', + 'number' => 'double', + 'bigint' => 'BigInt', + 'string' || 'id' => 'String', + 'bytes' => 'Uint8List', + _ => throw StateError('Unknown primitive $kind'), + }; + + @override + String decode(String value, String path) => switch (kind) { + 'null' => '_decodeNull($value, $path)', + 'boolean' => '_decodeBoolean($value, $path)', + 'number' => '_decodeNumber($value, $path)', + 'bigint' => '_decodeBigInt($value, $path)', + 'string' || 'id' => '_decodeString($value, $path)', + 'bytes' => '_decodeBytes($value, $path)', + _ => throw StateError('Unknown primitive $kind'), + }; + + @override + String encode(String value, String path) => switch (kind) { + 'null' => 'const ConvexNull()', + 'boolean' => 'ConvexBoolean($value)', + 'number' => '_encodeNumber($value, $path)', + 'bigint' => 'ConvexBigInt($value)', + 'string' || 'id' => 'ConvexString($value)', + 'bytes' => 'ConvexBytes($value)', + _ => throw StateError('Unknown primitive $kind'), + }; +} + +final class NullableMappedType extends MappedType { + const NullableMappedType(this.inner); + + final MappedType inner; + + @override + String get dartType => '${inner.dartType}?'; + + @override + String decode(String value, String path) => + '($value) is ConvexNull ? null : ${inner.decode(value, path)}'; + + @override + String encode(String value, String path) => + '$value == null ? const ConvexNull() : ${inner.encode('$value!', path)}'; +} + +final class ListMappedType extends MappedType { + const ListMappedType(this.item); + + final MappedType item; + + @override + String get dartType => 'List<${item.dartType}>'; + + @override + String decode(String value, String path) => + '_decodeArray($value, $path).value.indexed.map((entry) => ' + '${item.decode('entry.\$2', '_indexPath($path, entry.\$1)')}).toList(growable: false)'; + + @override + String encode(String value, String path) => + 'ConvexArray($value.indexed.map((entry) => ' + '${item.encode('entry.\$2', '_indexPath($path, entry.\$1)')}).toList(growable: false))'; +} + +final class MapMappedType extends MappedType { + const MapMappedType(this.valueType); + + final MappedType valueType; + + @override + String get dartType => 'Map'; + + @override + String decode(String value, String path) => + 'Map.unmodifiable(_decodeObject($value, $path).value.map((key, item) => ' + 'MapEntry(key, ${valueType.decode('item', '_fieldPath($path, key)')})))'; + + @override + String encode(String value, String path) => + 'ConvexObject($value.map((key, item) => ' + 'MapEntry(key, ${valueType.encode('item', '_fieldPath($path, key)')})))'; +} + +final class DeclarationMappedType extends MappedType { + const DeclarationMappedType(this.name); + + final String name; + + @override + String get dartType => name; + + @override + String decode(String value, String path) => '$name.decode($value, $path)'; + + @override + String encode(String value, String path) => '$value.encode($path)'; +} + +final class EnumMappedType extends MappedType { + const EnumMappedType(this.name); + + final String name; + + @override + String get dartType => name; + + @override + String decode(String value, String path) => + '$name.fromWireName(_decodeString($value, $path), $path)'; + + @override + String encode(String value, String path) => 'ConvexString($value.wireName)'; +} + +final class LiteralMappedType extends MappedType { + const LiteralMappedType(this.value, this.inner); + + final Object? value; + final MappedType inner; + + @override + String get dartType => inner.dartType; + + @override + String decode(String input, String path) { + final decoded = inner.decode(input, path); + return '_expectLiteral($decoded, ${jsonEncode(value)}, $path)'; + } + + @override + String encode(String input, String path) => + inner.encode('_expectLiteral($input, ${jsonEncode(value)}, $path)', path); +} + +final class OpaqueMappedType extends MappedType { + const OpaqueMappedType(this.binding); + + final PayloadBinding binding; + + @override + String get dartType => binding.dartType; + + @override + String decode(String value, String path) => + '_decodePayload(() => const ${binding.codecClass}().decode($value), $path)'; + + @override + String encode(String value, String path) => + '_encodePayload(() => const ${binding.codecClass}().encode($value), $path)'; +} + +final class OpaqueUnionMappedType extends MappedType { + const OpaqueUnionMappedType({required this.dartTypeName, required this.name}); + + final String dartTypeName; + final String name; + + @override + String get dartType => dartTypeName; + + @override + String decode(String value, String path) => '_decode$name($value, $path)'; + + @override + String encode(String value, String path) => '_encode$name($value, $path)'; +} + +final class RawMappedType extends MappedType { + const RawMappedType(this.validatorName); + + final String validatorName; + + @override + String get dartType => 'ConvexValue'; + + @override + String decode(String value, String path) => + '_decodeRaw($value, $path, $validatorName)'; + + @override + String encode(String value, String path) => + '_decodeRaw($value, $path, $validatorName)'; +} + +MappedContract mapContract( + ConvexContract contract, + Map payloadBindings, +) { + final mapper = _ContractMapper(contract, payloadBindings); + return mapper.map(); +} + +final class _ContractMapper { + _ContractMapper(this.contract, this.payloadBindings); + + final ConvexContract contract; + final Map payloadBindings; + final models = []; + final enums = []; + final unions = []; + final rawValidators = []; + final _modelBySignature = {}; + final _enumBySignature = {}; + final _unionBySignature = {}; + final _rawBySignature = {}; + final _declarationSignatures = {}; + final _usedPayloadTags = {}; + + MappedContract map() { + final mappedFunctions = []; + for (final function in contract.functions) { + final prefix = + '${pascalCase(function.moduleName)}${pascalCase(function.functionName)}'; + final argsFields = _mapObjectFields(function.args, '${prefix}Args'); + final resultType = _mapType(function.result, '${prefix}Result'); + mappedFunctions.add( + MappedFunction( + contract: function, + argsFields: argsFields, + resultType: resultType, + ), + ); + } + final missingBindings = payloadBindings.keys.toSet() + ..removeAll(_usedPayloadTags); + if (missingBindings.isNotEmpty) { + final sorted = missingBindings.toList()..sort(); + throw ContractException('Unused payload bindings: ${sorted.join(', ')}'); + } + return MappedContract( + contract: contract, + functions: mappedFunctions, + models: List.unmodifiable(models), + enums: List.unmodifiable(enums), + unions: List.unmodifiable(unions), + rawValidators: List.unmodifiable(rawValidators), + opaqueUnions: Map.unmodifiable(_opaqueUnions), + payloadBindings: payloadBindings, + ); + } + + MappedType _mapType(ConvexValidator validator, String suggestedName) { + return switch (validator) { + NullValidator() => const PrimitiveMappedType('null'), + BooleanValidator() => const PrimitiveMappedType('boolean'), + NumberValidator() => const PrimitiveMappedType('number'), + BigIntValidator() => const PrimitiveMappedType('bigint'), + StringValidator() => const PrimitiveMappedType('string'), + BytesValidator() => const PrimitiveMappedType('bytes'), + IdValidator() => const PrimitiveMappedType('id'), + LiteralValidator(:final value) => _mapLiteral(value, suggestedName), + ArrayValidator(:final item) => ListMappedType( + _mapType(item, '${suggestedName}Item'), + ), + ObjectValidator() => _mapObject(validator, suggestedName), + RecordValidator(:final keys, :final values) => _mapRecord( + keys, + values, + suggestedName, + ), + UnionValidator() => _mapUnion(validator, suggestedName), + }; + } + + MappedType _mapLiteral(Object? value, String suggestedName) { + if (value is String) { + return _mapEnum([value], suggestedName); + } + if (value == null) return const PrimitiveMappedType('null'); + if (value is bool) { + return LiteralMappedType(value, const PrimitiveMappedType('boolean')); + } + if (value is num) { + return LiteralMappedType(value, const PrimitiveMappedType('number')); + } + throw ContractException('$suggestedName has unsupported literal $value'); + } + + MappedType _mapObject(ObjectValidator validator, String suggestedName) { + final payloadTag = _payloadTag(validator); + if (payloadTag != null) { + final binding = payloadBindings[payloadTag]; + if (binding == null) { + throw ContractException('Missing payload binding for tag $payloadTag'); + } + _usedPayloadTags.add(payloadTag); + return OpaqueMappedType(binding); + } + final signature = validatorSignature(validator); + final existing = _modelBySignature[signature]; + if (existing != null) return DeclarationMappedType(existing.name); + final name = pascalCase(suggestedName); + _claimName(name, signature); + final fields = _mapObjectFields(validator, name); + final declaration = ModelDeclaration( + name: name, + fields: fields, + signature: signature, + ); + _modelBySignature[signature] = declaration; + models.add(declaration); + return DeclarationMappedType(name); + } + + List _mapObjectFields( + ObjectValidator validator, + String suggestedName, { + String? excludedField, + }) { + final fields = []; + final usedNames = {}; + for (final entry in validator.fields.entries) { + if (entry.key == excludedField) continue; + final dartName = lowerCamelCase(entry.key); + if (!usedNames.add(dartName)) { + throw ContractException( + '$suggestedName has colliding field names at ${entry.key}', + ); + } + fields.add( + MappedField( + wireName: entry.key, + dartName: dartName, + type: _mapType( + entry.value.validator, + '$suggestedName${pascalCase(entry.key)}', + ), + optional: entry.value.optional, + ), + ); + } + return List.unmodifiable(fields); + } + + MappedType _mapRecord( + ConvexValidator keys, + ConvexValidator values, + String suggestedName, + ) { + if (keys is! StringValidator) { + throw ContractException('$suggestedName record keys must be strings'); + } + return MapMappedType(_mapType(values, '${suggestedName}Value')); + } + + MappedType _mapUnion(UnionValidator validator, String suggestedName) { + final withoutNull = validator.members + .where((member) => member is! NullValidator) + .toList(); + final nullable = withoutNull.length != validator.members.length; + if (nullable && withoutNull.length == 1) { + return NullableMappedType(_mapType(withoutNull.single, suggestedName)); + } + if (withoutNull.every((member) => member is LiteralValidator)) { + final values = withoutNull + .cast() + .map((member) => member.value) + .toList(); + if (values.every((value) => value is String)) { + final mapped = _mapEnum(values.cast(), suggestedName); + return nullable ? NullableMappedType(mapped) : mapped; + } + } + if (withoutNull.every((member) => member is ObjectValidator)) { + final objectMembers = withoutNull.cast(); + final payloadTags = objectMembers.map(_payloadTag).toList(); + if (payloadTags.every((tag) => tag != null)) { + final bindings = payloadTags.map((tag) { + final binding = payloadBindings[tag]; + if (binding == null) { + throw ContractException('Missing payload binding for tag $tag'); + } + _usedPayloadTags.add(tag!); + return binding; + }).toList(); + final dartTypes = bindings.map((binding) => binding.dartType).toSet(); + if (dartTypes.length != 1) { + throw ContractException( + '$suggestedName payload union has incompatible Dart types', + ); + } + final name = pascalCase(suggestedName); + final mapped = OpaqueUnionMappedType( + dartTypeName: dartTypes.single, + name: name, + ); + _registerOpaqueUnion(name, bindings); + return nullable ? NullableMappedType(mapped) : mapped; + } + final discriminator = _findDiscriminator(objectMembers); + if (discriminator != null) { + final mapped = _mapDiscriminatedUnion( + validator, + objectMembers, + discriminator, + suggestedName, + ); + return nullable ? NullableMappedType(mapped) : mapped; + } + } + final signature = validatorSignature(validator); + final existing = _rawBySignature[signature]; + if (existing != null) return RawMappedType(existing.name); + final name = '_validate${pascalCase(suggestedName)}'; + _claimName(name, signature); + final declaration = RawValidatorDeclaration( + name: name, + validator: validator, + signature: signature, + ); + _rawBySignature[signature] = declaration; + rawValidators.add(declaration); + return RawMappedType(name); + } + + MappedType _mapEnum(List values, String suggestedName) { + final sorted = {...values}.toList()..sort(); + if (sorted.length != values.length) { + throw ContractException('$suggestedName has duplicate literal members'); + } + final signature = 'enum:${sorted.map(jsonEncode).join(',')}'; + final existing = _enumBySignature[signature]; + if (existing != null) return EnumMappedType(existing.name); + final name = pascalCase(suggestedName); + _claimName(name, signature); + final usedNames = {}; + final enumValues = []; + for (final wireValue in sorted) { + final enumName = lowerCamelCase(wireValue); + if (!usedNames.add(enumName)) { + throw ContractException('$name has colliding enum member $wireValue'); + } + enumValues.add( + EnumValueDeclaration(name: enumName, wireValue: wireValue), + ); + } + final declaration = EnumDeclaration( + name: name, + values: enumValues, + signature: signature, + ); + _enumBySignature[signature] = declaration; + enums.add(declaration); + return EnumMappedType(name); + } + + MappedType _mapDiscriminatedUnion( + UnionValidator validator, + List members, + String discriminator, + String suggestedName, + ) { + final signature = validatorSignature(validator); + final existing = _unionBySignature[signature]; + if (existing != null) return DeclarationMappedType(existing.name); + final name = pascalCase(suggestedName); + _claimName(name, signature); + final variants = []; + for (final member in members) { + final literal = + member.fields[discriminator]!.validator as LiteralValidator; + final wireValue = literal.value! as String; + final variantName = '$name${pascalCase(wireValue)}'; + final modelSignature = '${validatorSignature(member)}:base=$name'; + _claimName(variantName, modelSignature); + final model = ModelDeclaration( + name: variantName, + fields: _mapObjectFields( + member, + variantName, + excludedField: discriminator, + ), + signature: modelSignature, + baseName: name, + discriminatorName: discriminator, + discriminatorValue: wireValue, + ); + models.add(model); + variants.add( + UnionVariantDeclaration(discriminatorValue: wireValue, model: model), + ); + } + final declaration = UnionDeclaration( + name: name, + discriminatorName: discriminator, + variants: variants, + signature: signature, + ); + _unionBySignature[signature] = declaration; + unions.add(declaration); + return DeclarationMappedType(name); + } + + final _opaqueUnions = >{}; + + void _registerOpaqueUnion(String name, List bindings) { + final signature = bindings.map((binding) => binding.tag).join('|'); + final previous = _declarationSignatures[name]; + if (previous != null && previous != 'opaque:$signature') { + throw ContractException('Generated declaration name collision at $name'); + } + _declarationSignatures[name] = 'opaque:$signature'; + _opaqueUnions[name] = bindings; + } + + String? _payloadTag(ObjectValidator validator) { + final kind = validator.fields['kind']; + final version = validator.fields['payloadVersion']; + final data = validator.fields['data']; + if (kind == null || version == null || data == null) return null; + if (kind.optional || version.optional || data.optional) return null; + if (kind.validator case LiteralValidator(value: final String tag)) { + if (version.validator is NumberValidator && + data.validator is RecordValidator) { + return tag; + } + } + return null; + } + + String? _findDiscriminator(List members) { + final preferred = ['type', 'status', 'kind', 'targetType', 'provider']; + for (final name in preferred) { + final values = {}; + var valid = true; + for (final member in members) { + final field = member.fields[name]; + if (field == null || field.optional) { + valid = false; + break; + } + if (field.validator case LiteralValidator(value: final String value)) { + if (!values.add(value)) valid = false; + } else { + valid = false; + } + } + if (valid) return name; + } + return null; + } + + void _claimName(String name, String signature) { + final existing = _declarationSignatures[name]; + if (existing != null && existing != signature) { + throw ContractException('Generated declaration name collision at $name'); + } + _declarationSignatures[name] = signature; + } +} + +String validatorSignature(ConvexValidator validator) => switch (validator) { + NullValidator() => 'null', + BooleanValidator() => 'boolean', + NumberValidator() => 'number', + BigIntValidator() => 'bigint', + StringValidator() => 'string', + BytesValidator() => 'bytes', + LiteralValidator(:final value) => 'literal:${jsonEncode(value)}', + IdValidator(:final tableName) => 'id:$tableName', + ArrayValidator(:final item) => 'array:${validatorSignature(item)}', + ObjectValidator(:final fields) => + 'object:{${fields.entries.map((entry) => '${jsonEncode(entry.key)}:${entry.value.optional ? '?' : '!'}${validatorSignature(entry.value.validator)}').join(',')}}', + RecordValidator(:final keys, :final values) => + 'record:${validatorSignature(keys)}:${validatorSignature(values)}', + UnionValidator(:final members) => + 'union:[${members.map(validatorSignature).join(',')}]', +}; + +const _dartKeywords = { + 'abstract', + 'as', + 'assert', + 'async', + 'await', + 'base', + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'covariant', + 'default', + 'deferred', + 'do', + 'dynamic', + 'else', + 'enum', + 'export', + 'extends', + 'extension', + 'external', + 'factory', + 'false', + 'final', + 'finally', + 'for', + 'Function', + 'get', + 'hide', + 'if', + 'implements', + 'import', + 'in', + 'interface', + 'is', + 'late', + 'library', + 'mixin', + 'new', + 'null', + 'of', + 'on', + 'operator', + 'part', + 'required', + 'rethrow', + 'return', + 'sealed', + 'set', + 'show', + 'static', + 'super', + 'switch', + 'sync', + 'this', + 'throw', + 'true', + 'try', + 'typedef', + 'var', + 'void', + 'when', + 'while', + 'with', + 'yield', +}; + +String pascalCase(String value) { + final words = _words(value); + if (words.isEmpty) throw ContractException('Cannot map empty Dart name'); + return words + .map((word) => '${word[0].toUpperCase()}${word.substring(1)}') + .join(); +} + +String lowerCamelCase(String value) { + final pascal = pascalCase(value); + var result = '${pascal[0].toLowerCase()}${pascal.substring(1)}'; + if (_dartKeywords.contains(result)) result = '${result}Value'; + return result; +} + +List _words(String value) { + final separated = value + .replaceAllMapped( + RegExp(r'([a-z0-9])([A-Z])'), + (match) => '${match[1]} ${match[2]}', + ) + .replaceAll(RegExp(r'[^A-Za-z0-9]+'), ' ') + .trim(); + if (separated.isEmpty) return const []; + return separated + .split(RegExp(r'\s+')) + .map((word) => word.toLowerCase()) + .toList(); +} diff --git a/tool/icarus_convex_codegen/lib/src/parser.dart b/tool/icarus_convex_codegen/lib/src/parser.dart new file mode 100644 index 00000000..fad76c8b --- /dev/null +++ b/tool/icarus_convex_codegen/lib/src/parser.dart @@ -0,0 +1,197 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'contract.dart'; + +const allowedPublicIds = { + 'images.js:completeUpload.args.storageId:_storage', +}; + +ConvexContract parseContract({ + required File functionSpecFile, + required File errorCodesFile, +}) { + final decodedSpec = jsonDecode(functionSpecFile.readAsStringSync()); + if (decodedSpec is! Map || + decodedSpec.length != 1 || + decodedSpec['functions'] is! List) { + throw const ContractException( + 'function_spec.json must contain only a functions array', + ); + } + final functions = []; + final identifiers = {}; + for (final rawFunction in decodedSpec['functions'] as List) { + if (rawFunction is! Map) { + throw const ContractException('Function entries must be objects'); + } + final function = Map.from(rawFunction); + final visibility = function['visibility']; + if (visibility is! Map || visibility['kind'] != 'public') continue; + final identifier = function['identifier']; + if (identifier is! String || !identifiers.add(identifier)) { + throw ContractException('Duplicate or invalid function $identifier'); + } + final routeMatch = RegExp( + r'^([A-Za-z][A-Za-z0-9_]*)\.js:([A-Za-z][A-Za-z0-9_]*)$', + ).firstMatch(identifier); + if (routeMatch == null) { + throw ContractException('Invalid public function identifier $identifier'); + } + final args = function['args']; + final result = function['returns']; + if (args == null) { + throw ContractException('$identifier.args is missing a validator'); + } + if (result == null) { + throw ContractException('$identifier.returns is missing a validator'); + } + final parsedArgs = _parseValidator(args, '$identifier.args', identifier); + if (parsedArgs is! ObjectValidator) { + throw ContractException('$identifier.args must be an object validator'); + } + functions.add( + ConvexFunctionContract( + identifier: identifier, + moduleName: routeMatch.group(1)!, + functionName: routeMatch.group(2)!, + kind: switch (function['functionType']) { + 'Query' => ConvexFunctionKind.query, + 'Mutation' => ConvexFunctionKind.mutation, + 'Action' => ConvexFunctionKind.action, + final value => throw ContractException( + '$identifier has unknown function type $value', + ), + }, + args: parsedArgs, + result: _parseValidator(result, '$identifier.returns', identifier), + ), + ); + } + functions.sort((left, right) => left.identifier.compareTo(right.identifier)); + + final decodedCodes = jsonDecode(errorCodesFile.readAsStringSync()); + if (decodedCodes is! List || decodedCodes.any((code) => code is! String)) { + throw const ContractException('error_codes.json must be a string array'); + } + final errorCodes = decodedCodes.cast(); + final expectedCodes = {...errorCodes}.toList()..sort(); + if (expectedCodes.length != errorCodes.length || + !_sameStrings(expectedCodes, errorCodes)) { + throw const ContractException( + 'error_codes.json must be sorted without duplicates', + ); + } + return ConvexContract(functions: functions, errorCodes: errorCodes); +} + +ConvexValidator _parseValidator(Object? raw, String path, String identifier) { + if (raw is! Map) { + throw ContractException('$path is not a validator object'); + } + final validator = Map.from(raw); + return switch (validator['type']) { + 'null' => const NullValidator(), + 'boolean' => const BooleanValidator(), + 'number' => const NumberValidator(), + 'bigint' || 'int64' => const BigIntValidator(), + 'string' => const StringValidator(), + 'bytes' => const BytesValidator(), + 'literal' => LiteralValidator(validator['value']), + 'id' => _parseId(validator, path, identifier), + 'array' => ArrayValidator( + _parseValidator(validator['value'], '$path.item', identifier), + ), + 'object' => _parseObject(validator, path, identifier), + 'record' => _parseRecord(validator, path, identifier), + 'union' => _parseUnion(validator, path, identifier), + 'any' => throw ContractException('$path uses forbidden validator any'), + final type => throw ContractException( + '$path uses unsupported validator ${type ?? 'null'}', + ), + }; +} + +IdValidator _parseId( + Map validator, + String path, + String identifier, +) { + final tableName = validator['tableName']; + if (tableName is! String) { + throw ContractException('$path has an invalid id table'); + } + final allowlistKey = '$path:$tableName'; + if (!allowedPublicIds.contains(allowlistKey)) { + throw ContractException('$path exposes Convex id $tableName'); + } + return IdValidator(tableName); +} + +ObjectValidator _parseObject( + Map validator, + String path, + String identifier, +) { + final rawFields = validator['value']; + if (rawFields is! Map) { + throw ContractException('$path has invalid object fields'); + } + final fields = {}; + for (final entry in rawFields.entries) { + if (entry.key is! String || entry.value is! Map) { + throw ContractException('$path has an invalid object field'); + } + final field = Map.from(entry.value as Map); + if (field['optional'] is! bool || field['fieldType'] == null) { + throw ContractException('$path.${entry.key} has an invalid field'); + } + fields[entry.key as String] = ObjectField( + validator: _parseValidator( + field['fieldType'], + '$path.${entry.key}', + identifier, + ), + optional: field['optional'] as bool, + ); + } + return ObjectValidator(Map.unmodifiable(fields)); +} + +RecordValidator _parseRecord( + Map validator, + String path, + String identifier, +) { + final values = validator['values']; + if (values is! Map || values['fieldType'] == null) { + throw ContractException('$path has invalid record values'); + } + return RecordValidator( + keys: _parseValidator(validator['keys'], '$path.key', identifier), + values: _parseValidator(values['fieldType'], '$path.value', identifier), + ); +} + +UnionValidator _parseUnion( + Map validator, + String path, + String identifier, +) { + final rawMembers = validator['value']; + if (rawMembers is! List || rawMembers.isEmpty) { + throw ContractException('$path has an empty union'); + } + return UnionValidator([ + for (var index = 0; index < rawMembers.length; index += 1) + _parseValidator(rawMembers[index], '$path.union$index', identifier), + ]); +} + +bool _sameStrings(List left, List right) { + if (left.length != right.length) return false; + for (var index = 0; index < left.length; index += 1) { + if (left[index] != right[index]) return false; + } + return true; +} diff --git a/tool/icarus_convex_codegen/lib/src/payload_bindings.dart b/tool/icarus_convex_codegen/lib/src/payload_bindings.dart new file mode 100644 index 00000000..41225cdc --- /dev/null +++ b/tool/icarus_convex_codegen/lib/src/payload_bindings.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; + +import 'contract.dart'; + +Future> scanPayloadBindings(File sourceFile) async { + final sourcePath = sourceFile.absolute.path; + final collection = AnalysisContextCollection(includedPaths: [sourcePath]); + final session = collection.contextFor(sourcePath).currentSession; + final result = await session.getResolvedUnit(sourcePath); + if (result is! ResolvedUnitResult) { + throw ContractException('Unable to resolve ${sourceFile.path}'); + } + final analysisErrors = result.errors + .where((diagnostic) => diagnostic.severity.name == 'ERROR') + .toList(); + if (analysisErrors.isNotEmpty) { + throw ContractException( + 'Payload codec library does not analyze: ${analysisErrors.join('; ')}', + ); + } + + final bindings = {}; + for (final declaration + in result.unit.declarations.whereType()) { + Annotation? payloadAnnotation; + for (final annotation in declaration.metadata) { + if (annotation.name.name == 'ConvexPayload') { + payloadAnnotation = annotation; + break; + } + } + if (payloadAnnotation == null) continue; + final arguments = payloadAnnotation.arguments?.arguments; + if (arguments == null || + arguments.length != 1 || + arguments.single is! SimpleStringLiteral) { + throw ContractException( + '${declaration.name.lexeme} must use @ConvexPayload with one string tag', + ); + } + final tag = (arguments.single as SimpleStringLiteral).value; + String? dartType; + final interfaces = declaration.implementsClause?.interfaces ?? const []; + for (final interface in interfaces) { + if (interface.name2.lexeme != 'ConvexPayloadCodec') continue; + final typeArguments = interface.typeArguments?.arguments; + if (typeArguments == null || typeArguments.length != 1) { + throw ContractException( + '${declaration.name.lexeme} must implement ConvexPayloadCodec', + ); + } + dartType = typeArguments.single.toSource(); + } + if (dartType == null) { + throw ContractException( + '${declaration.name.lexeme} must directly implement ConvexPayloadCodec', + ); + } + if (bindings.containsKey(tag)) { + throw ContractException('Duplicate payload binding for tag $tag'); + } + bindings[tag] = PayloadBinding( + tag: tag, + codecClass: declaration.name.lexeme, + dartType: dartType, + ); + } + return Map.unmodifiable(bindings); +} diff --git a/tool/icarus_convex_codegen/pubspec.lock b/tool/icarus_convex_codegen/pubspec.lock new file mode 100644 index 00000000..1905206b --- /dev/null +++ b/tool/icarus_convex_codegen/pubspec.lock @@ -0,0 +1,397 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: "direct main" + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" + url: "https://pub.dev" + source: hosted + version: "1.15.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dart_style: + dependency: "direct main" + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: "direct main" + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" + url: "https://pub.dev" + source: hosted + version: "0.10.14" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" +sdks: + dart: ">=3.11.0 <4.0.0" diff --git a/tool/icarus_convex_codegen/pubspec.yaml b/tool/icarus_convex_codegen/pubspec.yaml new file mode 100644 index 00000000..116cd28f --- /dev/null +++ b/tool/icarus_convex_codegen/pubspec.yaml @@ -0,0 +1,13 @@ +name: icarus_convex_codegen +publish_to: none + +environment: + sdk: ">=3.11.0 <4.0.0" + +dependencies: + analyzer: 7.6.0 + dart_style: 3.1.1 + path: 1.9.1 + +dev_dependencies: + test: 1.26.3 diff --git a/tool/icarus_convex_codegen/test/fixtures/error_codes.json b/tool/icarus_convex_codegen/test/fixtures/error_codes.json new file mode 100644 index 00000000..89dfaeee --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/error_codes.json @@ -0,0 +1,4 @@ +[ + "CONFLICT", + "UNAUTHENTICATED" +] diff --git a/tool/icarus_convex_codegen/test/fixtures/missing_return.json b/tool/icarus_convex_codegen/test/fixtures/missing_return.json new file mode 100644 index 00000000..d5242d5a --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/missing_return.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "args": { "type": "object", "value": {} }, + "functionType": "Query", + "identifier": "fixture.js:missingReturn", + "returns": null, + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/icarus_convex_codegen/test/fixtures/name_collision.json b/tool/icarus_convex_codegen/test/fixtures/name_collision.json new file mode 100644 index 00000000..f4e8ebc2 --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/name_collision.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "args": { "type": "object", "value": { "foo-bar": { "fieldType": { "type": "string" }, "optional": false }, "foo_bar": { "fieldType": { "type": "string" }, "optional": false } } }, + "functionType": "Query", + "identifier": "fixture.js:nameCollision", + "returns": { "type": "null" }, + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/icarus_convex_codegen/test/fixtures/positive_all_nodes.json b/tool/icarus_convex_codegen/test/fixtures/positive_all_nodes.json new file mode 100644 index 00000000..2fe6d044 --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/positive_all_nodes.json @@ -0,0 +1,33 @@ +{ + "functions": [ + { + "args": { + "type": "object", + "value": { + "arrayValue": { "fieldType": { "type": "array", "value": { "type": "string" } }, "optional": false }, + "bigintValue": { "fieldType": { "type": "bigint" }, "optional": false }, + "booleanValue": { "fieldType": { "type": "boolean" }, "optional": false }, + "bytesValue": { "fieldType": { "type": "bytes" }, "optional": false }, + "literalValue": { "fieldType": { "type": "literal", "value": "fixed" }, "optional": false }, + "nullValue": { "fieldType": { "type": "null" }, "optional": false }, + "numberValue": { "fieldType": { "type": "number" }, "optional": false }, + "objectValue": { "fieldType": { "type": "object", "value": { "name": { "fieldType": { "type": "string" }, "optional": false } } }, "optional": false }, + "optionalValue": { "fieldType": { "type": "string" }, "optional": true }, + "recordValue": { "fieldType": { "type": "record", "keys": { "type": "string" }, "values": { "fieldType": { "type": "number" }, "optional": false } }, "optional": false }, + "stringValue": { "fieldType": { "type": "string" }, "optional": false }, + "unionValue": { "fieldType": { "type": "union", "value": [ { "type": "literal", "value": "first" }, { "type": "literal", "value": "second" } ] }, "optional": false } + } + }, + "functionType": "Query", + "identifier": "fixture.js:allNodes", + "returns": { + "type": "union", + "value": [ + { "type": "object", "value": { "kind": { "fieldType": { "type": "literal", "value": "ready" }, "optional": false }, "value": { "fieldType": { "type": "string" }, "optional": false } } }, + { "type": "object", "value": { "kind": { "fieldType": { "type": "literal", "value": "waiting" }, "optional": false }, "remaining": { "fieldType": { "type": "number" }, "optional": false } } } + ] + }, + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/icarus_convex_codegen/test/fixtures/public_id.json b/tool/icarus_convex_codegen/test/fixtures/public_id.json new file mode 100644 index 00000000..57ce3024 --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/public_id.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "args": { "type": "object", "value": { "documentId": { "fieldType": { "type": "id", "tableName": "strategies" }, "optional": false } } }, + "functionType": "Query", + "identifier": "fixture.js:publicId", + "returns": { "type": "null" }, + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/icarus_convex_codegen/test/fixtures/unsupported_validator.json b/tool/icarus_convex_codegen/test/fixtures/unsupported_validator.json new file mode 100644 index 00000000..af7cac37 --- /dev/null +++ b/tool/icarus_convex_codegen/test/fixtures/unsupported_validator.json @@ -0,0 +1,11 @@ +{ + "functions": [ + { + "args": { "type": "object", "value": { "future": { "fieldType": { "type": "future-validator" }, "optional": false } } }, + "functionType": "Query", + "identifier": "fixture.js:unsupported", + "returns": { "type": "null" }, + "visibility": { "kind": "public" } + } + ] +} diff --git a/tool/icarus_convex_codegen/test/generator_test.dart b/tool/icarus_convex_codegen/test/generator_test.dart new file mode 100644 index 00000000..cd35e4da --- /dev/null +++ b/tool/icarus_convex_codegen/test/generator_test.dart @@ -0,0 +1,477 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:icarus_convex_codegen/icarus_convex_codegen.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + final packageRoot = Directory.current.absolute; + final fixtures = Directory(path.join(packageRoot.path, 'test', 'fixtures')); + final errorCodes = File(path.join(fixtures.path, 'error_codes.json')); + + test( + 'positive fixture maps every supported schema node deterministically', + () { + final contract = parseContract( + functionSpecFile: File( + path.join(fixtures.path, 'positive_all_nodes.json'), + ), + errorCodesFile: errorCodes, + ); + final first = emitContract(mapContract(contract, const {})); + final second = emitContract(mapContract(contract, const {})); + + expect(first, equals(second)); + expect(first.keys, { + 'convex_error_codes.dart', + 'convex_models.dart', + 'generated.dart', + 'icarus_convex_api.dart', + }); + expect(first['convex_models.dart'], contains('Uint8List')); + expect(first['convex_models.dart'], contains('BigInt')); + expect( + first['convex_models.dart'], + contains('sealed class FixtureAllNodesResult'), + ); + expect(first['icarus_convex_api.dart'], contains('ConvexQuery<')); + }, + ); + + test('positive fixture generates analyzable standalone libraries', () async { + final contract = parseContract( + functionSpecFile: File( + path.join(fixtures.path, 'positive_all_nodes.json'), + ), + errorCodesFile: errorCodes, + ); + final directory = Directory.systemTemp.createTempSync('icarus-analyze-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final generated = Directory( + path.join(directory.path, 'lib', 'collab', 'generated'), + ); + writeGeneratedOutput( + generated, + emitContract(mapContract(contract, const {})), + ); + _writeAnalysisStubs(directory, packageRoot); + final analysis = await Process.run(Platform.resolvedExecutable, [ + 'analyze', + path.join(directory.path, 'lib'), + ]); + expect( + analysis.exitCode, + 0, + reason: '${analysis.stdout}${analysis.stderr}', + ); + }); + + test('negative fixtures fail with their golden diagnostic', () { + final goldenLines = File( + path.join(packageRoot.path, 'test', 'goldens', 'failures.txt'), + ).readAsLinesSync(); + for (final line in goldenLines) { + final separator = line.indexOf('|'); + final fixtureName = line.substring(0, separator); + final expected = line.substring(separator + 1); + expect( + () { + final contract = parseContract( + functionSpecFile: File(path.join(fixtures.path, fixtureName)), + errorCodesFile: errorCodes, + ); + mapContract(contract, const {}); + }, + throwsA( + isA().having( + (error) => error.message, + fixtureName, + expected, + ), + ), + ); + } + }); + + test('any is rejected explicitly', () { + final directory = Directory.systemTemp.createTempSync('icarus-any-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final spec = File(path.join(directory.path, 'spec.json')) + ..writeAsStringSync( + jsonEncode( + _singleFunction( + args: _object({ + 'value': _field({'type': 'any'}), + }), + result: {'type': 'null'}, + ), + ), + ); + expect( + () => parseContract(functionSpecFile: spec, errorCodesFile: errorCodes), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('uses forbidden validator any'), + ), + ), + ); + }); + + test( + 'payload scan resolves generic type and rejects duplicate tags', + () async { + final directory = Directory.systemTemp.createTempSync('icarus-codecs-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final valid = File(path.join(directory.path, 'valid.dart')) + ..writeAsStringSync(_codecSource(secondTag: 'drawing')); + final bindings = await scanPayloadBindings(valid); + expect(bindings['agent']?.dartType, 'Map'); + expect(bindings['drawing']?.codecClass, 'SecondCodec'); + + final duplicate = File(path.join(directory.path, 'duplicate.dart')) + ..writeAsStringSync(_codecSource(secondTag: 'agent')); + await expectLater( + scanPayloadBindings(duplicate), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Duplicate payload binding for tag agent', + ), + ), + ); + }, + ); + + test('unbound and unused payload tags fail closed', () { + final contract = ConvexContract( + functions: [ + ConvexFunctionContract( + identifier: 'fixture.js:payload', + moduleName: 'fixture', + functionName: 'payload', + kind: ConvexFunctionKind.query, + args: const ObjectValidator({}), + result: _payloadEnvelope('agent'), + ), + ], + errorCodes: const [], + ); + expect( + () => mapContract(contract, const {}), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Missing payload binding for tag agent', + ), + ), + ); + expect( + () => mapContract( + const ConvexContract(functions: [], errorCodes: []), + const { + 'agent': PayloadBinding( + tag: 'agent', + codecClass: 'AgentCodec', + dartType: 'Object', + ), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'Unused payload bindings: agent', + ), + ), + ); + }); + + test('owned output recreation removes stale files', () { + final directory = Directory.systemTemp.createTempSync('icarus-output-'); + addTearDown(() => directory.deleteSync(recursive: true)); + File(path.join(directory.path, 'stale.dart')) + ..createSync() + ..writeAsStringSync('stale'); + writeGeneratedOutput(directory, const {'current.dart': 'current\n'}); + expect(File(path.join(directory.path, 'stale.dart')).existsSync(), isFalse); + expect( + File(path.join(directory.path, 'current.dart')).readAsStringSync(), + 'current\n', + ); + }); + + test('contract mutations change output or stop generation', () { + final baselineSpec = _mutationSpec(); + final baseline = _emitInMemory(baselineSpec, ['CONFLICT']); + + final mutations = >[ + _deepCopy(baselineSpec) + ..['functions'] = [ + ...((baselineSpec['functions'] as List).cast>()) + .map( + (function) => {...function, 'identifier': 'sample.js:renamed'}, + ), + ], + _renameField(baselineSpec, area: 'args', from: 'name', to: 'title'), + _renameField(baselineSpec, area: 'returns', from: 'name', to: 'title'), + _replaceEnumMember(baselineSpec, 'first', 'second'), + ]; + for (final mutation in mutations) { + expect(_emitInMemory(mutation, ['CONFLICT']), isNot(equals(baseline))); + } + expect(_emitInMemory(baselineSpec, ['FORBIDDEN']), isNot(equals(baseline))); + }); + + test('breaking mutations fail an unchanged typed caller', () async { + final directory = Directory.systemTemp.createTempSync('icarus-caller-'); + addTearDown(() => directory.deleteSync(recursive: true)); + final generated = Directory( + path.join(directory.path, 'lib', 'collab', 'generated'), + ); + _writeAnalysisStubs(directory, packageRoot); + final caller = File(path.join(directory.path, 'bin', 'caller.dart')) + ..createSync(recursive: true) + ..writeAsStringSync(''' +import '../lib/collab/generated/generated.dart'; + +Future unchangedCaller(IcarusConvexApi api) async { + final result = await api.sample + .read(name: 'name', mode: SampleReadArgsMode.first) + .fetch(); + if (ConvexErrorCode.conflict.wireName.isEmpty) throw StateError('code'); + return result.name; +} +'''); + + Future analyzeWith( + Map spec, + List codes, + ) async { + writeGeneratedOutput(generated, _emitInMemory(spec, codes)); + return Process.run(Platform.resolvedExecutable, [ + 'analyze', + caller.path, + generated.path, + ]); + } + + final baselineSpec = _mutationSpec(); + final baseline = await analyzeWith(baselineSpec, ['CONFLICT']); + expect( + baseline.exitCode, + 0, + reason: '${baseline.stdout}${baseline.stderr}', + ); + + final mutations = <({Map spec, List codes})>[ + ( + spec: _deepCopy(baselineSpec) + ..['functions'] = [ + ...((baselineSpec['functions'] as List) + .cast>()) + .map( + (function) => { + ...function, + 'identifier': 'sample.js:renamed', + }, + ), + ], + codes: ['CONFLICT'], + ), + ( + spec: _renameField( + baselineSpec, + area: 'args', + from: 'name', + to: 'title', + ), + codes: ['CONFLICT'], + ), + ( + spec: _renameField( + baselineSpec, + area: 'returns', + from: 'name', + to: 'title', + ), + codes: ['CONFLICT'], + ), + ( + spec: _replaceEnumMember(baselineSpec, 'first', 'second'), + codes: ['CONFLICT'], + ), + (spec: baselineSpec, codes: ['FORBIDDEN']), + ]; + for (final mutation in mutations) { + final result = await analyzeWith(mutation.spec, mutation.codes); + expect( + result.exitCode, + isNot(0), + reason: 'Mutation unexpectedly preserved the caller:\n${result.stdout}', + ); + } + }); +} + +void _writeAnalysisStubs(Directory directory, Directory packageRoot) { + final transport = File( + path.join( + directory.path, + 'lib', + 'collab', + 'transport', + 'convex_transport.dart', + ), + )..createSync(recursive: true); + transport.writeAsStringSync( + File( + path.normalize( + path.join( + packageRoot.path, + '..', + '..', + 'lib', + 'collab', + 'transport', + 'convex_transport.dart', + ), + ), + ).readAsStringSync(), + ); + File(path.join(directory.path, 'lib', 'collab', 'convex_payload_codecs.dart')) + ..createSync(recursive: true) + ..writeAsStringSync('// No opaque payloads in this fixture.\n'); +} + +Map _emitInMemory( + Map spec, + List codes, +) { + final directory = Directory.systemTemp.createTempSync('icarus-mutation-'); + try { + final specFile = File(path.join(directory.path, 'spec.json')) + ..writeAsStringSync(jsonEncode(spec)); + final codesFile = File(path.join(directory.path, 'codes.json')) + ..writeAsStringSync(jsonEncode(codes)); + return emitContract( + mapContract( + parseContract(functionSpecFile: specFile, errorCodesFile: codesFile), + const {}, + ), + ); + } finally { + directory.deleteSync(recursive: true); + } +} + +Map _mutationSpec() => _singleFunction( + identifier: 'sample.js:read', + args: _object({ + 'mode': _field({ + 'type': 'union', + 'value': [ + {'type': 'literal', 'value': 'first'}, + {'type': 'literal', 'value': 'last'}, + ], + }), + 'name': _field({'type': 'string'}), + }), + result: _object({ + 'name': _field({'type': 'string'}), + }), +); + +Map _renameField( + Map source, { + required String area, + required String from, + required String to, +}) { + final copy = _deepCopy(source); + final function = ((copy['functions'] as List).single as Map); + final validator = function[area] as Map; + final fields = validator['value'] as Map; + fields[to] = fields.remove(from); + return copy; +} + +Map _replaceEnumMember( + Map source, + String from, + String to, +) { + final copy = _deepCopy(source); + final function = ((copy['functions'] as List).single as Map); + final args = function['args'] as Map; + final mode = + ((args['value'] as Map)['mode'] + as Map)['fieldType'] + as Map; + final members = mode['value'] as List; + for (final member in members.cast>()) { + if (member['value'] == from) member['value'] = to; + } + return copy; +} + +Map _deepCopy(Map source) => + Map.from(jsonDecode(jsonEncode(source)) as Map); + +Map _singleFunction({ + String identifier = 'fixture.js:test', + required Map args, + required Map result, +}) => { + 'functions': [ + { + 'args': args, + 'functionType': 'Query', + 'identifier': identifier, + 'returns': result, + 'visibility': {'kind': 'public'}, + }, + ], +}; + +Map _object(Map fields) => { + 'type': 'object', + 'value': fields, +}; + +Map _field(Map validator) => { + 'fieldType': validator, + 'optional': false, +}; + +ObjectValidator _payloadEnvelope(String tag) => ObjectValidator({ + 'kind': ObjectField(validator: LiteralValidator(tag), optional: false), + 'payloadVersion': const ObjectField( + validator: NumberValidator(), + optional: false, + ), + 'data': const ObjectField( + validator: RecordValidator( + keys: StringValidator(), + values: StringValidator(), + ), + optional: false, + ), +}); + +String _codecSource({required String secondTag}) => + ''' +class ConvexPayload { + const ConvexPayload(this.tag); + final String tag; +} +abstract interface class ConvexPayloadCodec {} +@ConvexPayload('agent') +final class AgentCodec implements ConvexPayloadCodec> {} +@ConvexPayload('$secondTag') +final class SecondCodec implements ConvexPayloadCodec> {} +'''; diff --git a/tool/icarus_convex_codegen/test/goldens/failures.txt b/tool/icarus_convex_codegen/test/goldens/failures.txt new file mode 100644 index 00000000..90a969d1 --- /dev/null +++ b/tool/icarus_convex_codegen/test/goldens/failures.txt @@ -0,0 +1,4 @@ +missing_return.json|fixture.js:missingReturn.returns is missing a validator +unsupported_validator.json|fixture.js:unsupported.args.future uses unsupported validator future-validator +public_id.json|fixture.js:publicId.args.documentId exposes Convex id strategies +name_collision.json|FixtureNameCollisionArgs has colliding field names at foo_bar diff --git a/tool/snapshot_convex_contract.mjs b/tool/snapshot_convex_contract.mjs new file mode 100644 index 00000000..f33228fd --- /dev/null +++ b/tool/snapshot_convex_contract.mjs @@ -0,0 +1,90 @@ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const toolDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(toolDirectory, ".."); +const functionSpecPath = resolve(repositoryRoot, "convex/function_spec.json"); +const errorCodesPath = resolve(repositoryRoot, "convex/error_codes.json"); +const errorsSourcePath = resolve(repositoryRoot, "convex/lib/errors.ts"); +const checkOnly = process.argv.includes("--check"); + +function sortObjectKeys(value) { + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + if (value === null || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortObjectKeys(child)]), + ); +} + +function readFunctionSpec() { + const executable = process.execPath; + const args = [ + resolve(repositoryRoot, "node_modules", "convex", "bin", "main.js"), + "function-spec", + ]; + const previewName = process.env.CONVEX_PREVIEW_NAME; + if (previewName) { + args.push("--preview-name", previewName); + } + const raw = execFileSync(executable, args, { + cwd: repositoryRoot, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + }); + const liveSpec = JSON.parse(raw); + return sortObjectKeys({ + functions: [...liveSpec.functions].sort((left, right) => + left.identifier.localeCompare(right.identifier), + ), + }); +} + +function readErrorCodes() { + const source = readFileSync(errorsSourcePath, "utf8"); + const declaration = source.match( + /export const errorCodes\s*=\s*\[([\s\S]*?)\]\s*as const/, + ); + if (declaration === null) { + throw new Error("convex/lib/errors.ts must export errorCodes as a const array"); + } + const codes = [...declaration[1].matchAll(/"([A-Z][A-Z0-9_]*)"/g)].map( + (match) => match[1], + ); + if (codes.length === 0) { + throw new Error("errorCodes must contain at least one code"); + } + return [...new Set(codes)].sort(); +} + +function asJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function writeOrCheck(path, contents) { + if (!checkOnly) { + writeFileSync(path, contents); + return; + } + const committed = readFileSync(path, "utf8"); + if (committed.replaceAll("\r\n", "\n") !== contents) { + throw new Error(`${path} is stale; run npm run snapshot:convex-contract`); + } +} + +writeOrCheck(functionSpecPath, asJson(readFunctionSpec())); +writeOrCheck(errorCodesPath, asJson(readErrorCodes())); + +console.log( + checkOnly + ? "Convex contract snapshots are current." + : "Wrote scrubbed Convex contract snapshots.", +); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..131a1ae2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "exclude": ["node_modules", "third_party", "presence"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..66f217c8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "edge-runtime", + include: ["convex/**/*.test.ts"], + }, +}); diff --git a/web/CONVEX_BROWSER_CLIENT.md b/web/CONVEX_BROWSER_CLIENT.md new file mode 100644 index 00000000..4b2de306 --- /dev/null +++ b/web/CONVEX_BROWSER_CLIENT.md @@ -0,0 +1,11 @@ +# Convex browser client + +`convex.browser.bundle.js` is the unmodified browser bundle produced by the +Apache-2.0-licensed `convex` npm package. It is checked in so the web client does +not depend on a third-party CDN at runtime. + +Regenerate it from the repository root after updating the pinned npm dependency: + +```sh +cp node_modules/convex/dist/browser.bundle.js web/convex.browser.bundle.js +``` diff --git a/web/_headers b/web/_headers new file mode 100644 index 00000000..2ded2de1 --- /dev/null +++ b/web/_headers @@ -0,0 +1,17 @@ +# Flutter does not hash its output filenames, so these files keep the same URL +# across deploys. no-cache makes browsers revalidate them on every load, so +# testers get a new deploy on refresh. Everything else keeps Cloudflare's +# default caching. `/` is listed because the app loads from the root, not +# from /index.html. +/ + Cache-Control: no-cache +/index.html + Cache-Control: no-cache +/flutter_bootstrap.js + Cache-Control: no-cache +/flutter_service_worker.js + Cache-Control: no-cache +/main.dart.js + Cache-Control: no-cache +/manifest.json + Cache-Control: no-cache diff --git a/web/convex.browser.bundle.js b/web/convex.browser.bundle.js new file mode 100644 index 00000000..82026ce2 --- /dev/null +++ b/web/convex.browser.bundle.js @@ -0,0 +1,4647 @@ +"use strict"; +var convex = (() => { + var __defProp = Object.defineProperty; + var __getOwnPropDesc = Object.getOwnPropertyDescriptor; + var __getOwnPropNames = Object.getOwnPropertyNames; + var __hasOwnProp = Object.prototype.hasOwnProperty; + var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); + }; + var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; + }; + var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + + // browser-bundle.js + var browser_bundle_exports = {}; + __export(browser_bundle_exports, { + BaseConvexClient: () => BaseConvexClient, + ConvexClient: () => ConvexClient, + ConvexHttpClient: () => ConvexHttpClient, + anyApi: () => anyApi, + convexQueryOptions: () => convexQueryOptions + }); + + // src/index.ts + var version = "1.45.0"; + + // src/values/base64.ts + var base64_exports = {}; + __export(base64_exports, { + byteLength: () => byteLength, + fromByteArray: () => fromByteArray, + fromByteArrayUrlSafeNoPadding: () => fromByteArrayUrlSafeNoPadding, + toByteArray: () => toByteArray + }); + var lookup = []; + var revLookup = []; + var Arr = Uint8Array; + var code = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + for (i = 0, len = code.length; i < len; ++i) { + lookup[i] = code[i]; + revLookup[code.charCodeAt(i)] = i; + } + var i; + var len; + revLookup["-".charCodeAt(0)] = 62; + revLookup["_".charCodeAt(0)] = 63; + function getLens(b64) { + var len = b64.length; + if (len % 4 > 0) { + throw new Error("Invalid string. Length must be a multiple of 4"); + } + var validLen = b64.indexOf("="); + if (validLen === -1) validLen = len; + var placeHoldersLen = validLen === len ? 0 : 4 - validLen % 4; + return [validLen, placeHoldersLen]; + } + function byteLength(b64) { + var lens = getLens(b64); + var validLen = lens[0]; + var placeHoldersLen = lens[1]; + return (validLen + placeHoldersLen) * 3 / 4 - placeHoldersLen; + } + function _byteLength(_b64, validLen, placeHoldersLen) { + return (validLen + placeHoldersLen) * 3 / 4 - placeHoldersLen; + } + function toByteArray(b64) { + var tmp; + var lens = getLens(b64); + var validLen = lens[0]; + var placeHoldersLen = lens[1]; + var arr = new Arr(_byteLength(b64, validLen, placeHoldersLen)); + var curByte = 0; + var len = placeHoldersLen > 0 ? validLen - 4 : validLen; + var i; + for (i = 0; i < len; i += 4) { + tmp = revLookup[b64.charCodeAt(i)] << 18 | revLookup[b64.charCodeAt(i + 1)] << 12 | revLookup[b64.charCodeAt(i + 2)] << 6 | revLookup[b64.charCodeAt(i + 3)]; + arr[curByte++] = tmp >> 16 & 255; + arr[curByte++] = tmp >> 8 & 255; + arr[curByte++] = tmp & 255; + } + if (placeHoldersLen === 2) { + tmp = revLookup[b64.charCodeAt(i)] << 2 | revLookup[b64.charCodeAt(i + 1)] >> 4; + arr[curByte++] = tmp & 255; + } + if (placeHoldersLen === 1) { + tmp = revLookup[b64.charCodeAt(i)] << 10 | revLookup[b64.charCodeAt(i + 1)] << 4 | revLookup[b64.charCodeAt(i + 2)] >> 2; + arr[curByte++] = tmp >> 8 & 255; + arr[curByte++] = tmp & 255; + } + return arr; + } + function tripletToBase64(num) { + return lookup[num >> 18 & 63] + lookup[num >> 12 & 63] + lookup[num >> 6 & 63] + lookup[num & 63]; + } + function encodeChunk(uint8, start, end) { + var tmp; + var output = []; + for (var i = start; i < end; i += 3) { + tmp = (uint8[i] << 16 & 16711680) + (uint8[i + 1] << 8 & 65280) + (uint8[i + 2] & 255); + output.push(tripletToBase64(tmp)); + } + return output.join(""); + } + function fromByteArray(uint8) { + var tmp; + var len = uint8.length; + var extraBytes = len % 3; + var parts = []; + var maxChunkLength = 16383; + for (var i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push( + encodeChunk( + uint8, + i, + i + maxChunkLength > len2 ? len2 : i + maxChunkLength + ) + ); + } + if (extraBytes === 1) { + tmp = uint8[len - 1]; + parts.push(lookup[tmp >> 2] + lookup[tmp << 4 & 63] + "=="); + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + parts.push( + lookup[tmp >> 10] + lookup[tmp >> 4 & 63] + lookup[tmp << 2 & 63] + "=" + ); + } + return parts.join(""); + } + function fromByteArrayUrlSafeNoPadding(uint8) { + return fromByteArray(uint8).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + } + + // src/common/index.ts + function parseArgs(args) { + if (args === void 0) { + return {}; + } + if (!isSimpleObject(args)) { + throw new Error( + `The arguments to a Convex function must be an object. Received: ${args}` + ); + } + return args; + } + function validateDeploymentUrl(deploymentUrl) { + if (typeof deploymentUrl === "undefined") { + throw new Error( + `Client created with undefined deployment address. If you used an environment variable, check that it's set.` + ); + } + if (typeof deploymentUrl !== "string") { + throw new Error( + `Invalid deployment address: found ${deploymentUrl}".` + ); + } + if (!(deploymentUrl.startsWith("http:") || deploymentUrl.startsWith("https:"))) { + throw new Error( + `Invalid deployment address: Must start with "https://" or "http://". Found "${deploymentUrl}".` + ); + } + try { + new URL(deploymentUrl); + } catch { + throw new Error( + `Invalid deployment address: "${deploymentUrl}" is not a valid URL. If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.` + ); + } + if (deploymentUrl.endsWith(".convex.site")) { + throw new Error( + `Invalid deployment address: "${deploymentUrl}" ends with .convex.site, which is used for HTTP Actions. Convex deployment URLs typically end with .convex.cloud? If you believe this URL is correct, use the \`skipConvexDeploymentUrlCheck\` option to bypass this.` + ); + } + } + function isSimpleObject(value) { + const isObject = typeof value === "object"; + const prototype = Object.getPrototypeOf(value); + const isSimple = prototype === null || prototype === Object.prototype || // Objects generated from other contexts (e.g. across Node.js `vm` modules) will not satisfy the previous + // conditions but are still simple objects. + prototype?.constructor?.name === "Object"; + return isObject && isSimple; + } + + // src/values/value.ts + var LITTLE_ENDIAN = true; + var MIN_INT64 = BigInt("-9223372036854775808"); + var MAX_INT64 = BigInt("9223372036854775807"); + var ZERO = BigInt("0"); + var EIGHT = BigInt("8"); + var TWOFIFTYSIX = BigInt("256"); + var COMMIT_TS_UNRESOLVED = "This commit timestamp is unresolved: its value is assigned when the mutation commits. Read the document after the mutation completes to get its value."; + var CommitTsPlaceholder = class { + [Symbol.toPrimitive](hint) { + if (hint === "string") { + return this.toString(); + } + throw new Error(COMMIT_TS_UNRESOLVED); + } + valueOf() { + throw new Error(COMMIT_TS_UNRESOLVED); + } + toJSON() { + throw new Error(COMMIT_TS_UNRESOLVED); + } + toString() { + return "[unresolved commit timestamp]"; + } + }; + var commitTsPlaceholder = new CommitTsPlaceholder(); + function isSpecial(n) { + return Number.isNaN(n) || !Number.isFinite(n) || Object.is(n, -0); + } + function slowBigIntToBase64(value) { + if (value < ZERO) { + value -= MIN_INT64 + MIN_INT64; + } + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = "0" + hex; + const bytes = new Uint8Array(new ArrayBuffer(8)); + let i = 0; + for (const hexByte of hex.match(/.{2}/g).reverse()) { + bytes.set([parseInt(hexByte, 16)], i++); + value >>= EIGHT; + } + return fromByteArray(bytes); + } + function slowBase64ToBigInt(encoded) { + const integerBytes = toByteArray(encoded); + if (integerBytes.byteLength !== 8) { + throw new Error( + `Received ${integerBytes.byteLength} bytes, expected 8 for $integer` + ); + } + let value = ZERO; + let power = ZERO; + for (const byte of integerBytes) { + value += BigInt(byte) * TWOFIFTYSIX ** power; + power++; + } + if (value > MAX_INT64) { + value += MIN_INT64 + MIN_INT64; + } + return value; + } + function modernBigIntToBase64(value) { + if (value < MIN_INT64 || MAX_INT64 < value) { + throw new Error( + `BigInt ${value} does not fit into a 64-bit signed integer.` + ); + } + const buffer = new ArrayBuffer(8); + new DataView(buffer).setBigInt64(0, value, true); + return fromByteArray(new Uint8Array(buffer)); + } + function modernBase64ToBigInt(encoded) { + const integerBytes = toByteArray(encoded); + if (integerBytes.byteLength !== 8) { + throw new Error( + `Received ${integerBytes.byteLength} bytes, expected 8 for $integer` + ); + } + const intBytesView = new DataView(integerBytes.buffer); + return intBytesView.getBigInt64(0, true); + } + var bigIntToBase64 = DataView.prototype.setBigInt64 ? modernBigIntToBase64 : slowBigIntToBase64; + var base64ToBigInt = DataView.prototype.getBigInt64 ? modernBase64ToBigInt : slowBase64ToBigInt; + var MAX_IDENTIFIER_LEN = 1024; + function validateObjectField(k) { + if (k.length > MAX_IDENTIFIER_LEN) { + throw new Error( + `Field name ${k} exceeds maximum field name length ${MAX_IDENTIFIER_LEN}.` + ); + } + if (k.startsWith("$")) { + throw new Error(`Field name ${k} starts with a '$', which is reserved.`); + } + for (let i = 0; i < k.length; i += 1) { + const charCode = k.charCodeAt(i); + if (charCode < 32 || charCode >= 127) { + throw new Error( + `Field name ${k} has invalid character '${k[i]}': Field names can only contain non-control ASCII characters` + ); + } + } + } + function jsonToConvex(value) { + if (value === null) { + return value; + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + return value; + } + if (Array.isArray(value)) { + return value.map((value2) => jsonToConvex(value2)); + } + if (typeof value !== "object") { + throw new Error(`Unexpected type of ${value}`); + } + const entries = Object.entries(value); + if (entries.length === 1) { + const key = entries[0][0]; + if (key === "$bytes") { + if (typeof value.$bytes !== "string") { + throw new Error(`Malformed $bytes field on ${value}`); + } + return toByteArray(value.$bytes).buffer; + } + if (key === "$integer") { + if (typeof value.$integer !== "string") { + throw new Error(`Malformed $integer field on ${value}`); + } + return base64ToBigInt(value.$integer); + } + if (key === "$float") { + if (typeof value.$float !== "string") { + throw new Error(`Malformed $float field on ${value}`); + } + const floatBytes = toByteArray(value.$float); + if (floatBytes.byteLength !== 8) { + throw new Error( + `Received ${floatBytes.byteLength} bytes, expected 8 for $float` + ); + } + const floatBytesView = new DataView(floatBytes.buffer); + const float = floatBytesView.getFloat64(0, LITTLE_ENDIAN); + if (!isSpecial(float)) { + throw new Error(`Float ${float} should be encoded as a number`); + } + return float; + } + if (key === "$commitTs") { + if (value.$commitTs !== null) { + throw new Error(`Malformed $commitTs field on ${value}`); + } + return commitTsPlaceholder; + } + if (key === "$set") { + throw new Error( + `Received a Set which is no longer supported as a Convex type.` + ); + } + if (key === "$map") { + throw new Error( + `Received a Map which is no longer supported as a Convex type.` + ); + } + } + const out = {}; + for (const [k, v] of Object.entries(value)) { + validateObjectField(k); + out[k] = jsonToConvex(v); + } + return out; + } + var MAX_VALUE_FOR_ERROR_LEN = 16384; + function stringifyValueForError(value) { + const str = JSON.stringify(value, (_key, value2) => { + if (value2 === void 0) { + return "undefined"; + } + if (typeof value2 === "bigint") { + return `${value2.toString()}n`; + } + return value2; + }); + if (str.length > MAX_VALUE_FOR_ERROR_LEN) { + const rest = "[...truncated]"; + let truncateAt = MAX_VALUE_FOR_ERROR_LEN - rest.length; + const codePoint = str.codePointAt(truncateAt - 1); + if (codePoint !== void 0 && codePoint > 65535) { + truncateAt -= 1; + } + return str.substring(0, truncateAt) + rest; + } + return str; + } + function convexToJsonInternal(value, originalValue, context, includeTopLevelUndefined) { + if (value === void 0) { + const contextText = context && ` (present at path ${context} in original object ${stringifyValueForError( + originalValue + )})`; + throw new Error( + `undefined is not a valid Convex value${contextText}. To learn about Convex's supported types, see https://docs.convex.dev/using/types.` + ); + } + if (value === null) { + return value; + } + if (typeof value === "bigint") { + if (value < MIN_INT64 || MAX_INT64 < value) { + throw new Error( + `BigInt ${value} does not fit into a 64-bit signed integer.` + ); + } + return { $integer: bigIntToBase64(value) }; + } + if (typeof value === "number") { + if (isSpecial(value)) { + const buffer = new ArrayBuffer(8); + new DataView(buffer).setFloat64(0, value, LITTLE_ENDIAN); + return { $float: fromByteArray(new Uint8Array(buffer)) }; + } else { + return value; + } + } + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + return value; + } + if (value instanceof ArrayBuffer) { + return { $bytes: fromByteArray(new Uint8Array(value)) }; + } + if (value instanceof CommitTsPlaceholder) { + return { $commitTs: null }; + } + if (Array.isArray(value)) { + return value.map( + (value2, i) => convexToJsonInternal(value2, originalValue, context + `[${i}]`, false) + ); + } + if (value instanceof Set) { + throw new Error( + errorMessageForUnsupportedType(context, "Set", [...value], originalValue) + ); + } + if (value instanceof Map) { + throw new Error( + errorMessageForUnsupportedType(context, "Map", [...value], originalValue) + ); + } + if (!isSimpleObject(value)) { + const theType = value?.constructor?.name; + const typeName = theType ? `${theType} ` : ""; + throw new Error( + errorMessageForUnsupportedType(context, typeName, value, originalValue) + ); + } + const out = {}; + const entries = Object.entries(value); + entries.sort(([k1, _v1], [k2, _v2]) => k1 === k2 ? 0 : k1 < k2 ? -1 : 1); + for (const [k, v] of entries) { + if (v !== void 0) { + validateObjectField(k); + out[k] = convexToJsonInternal(v, originalValue, context + `.${k}`, false); + } else if (includeTopLevelUndefined) { + validateObjectField(k); + out[k] = convexOrUndefinedToJsonInternal( + v, + originalValue, + context + `.${k}` + ); + } + } + return out; + } + function errorMessageForUnsupportedType(context, typeName, value, originalValue) { + if (context) { + return `${typeName}${stringifyValueForError( + value + )} is not a supported Convex type (present at path ${context} in original object ${stringifyValueForError( + originalValue + )}). To learn about Convex's supported types, see https://docs.convex.dev/using/types.`; + } else { + return `${typeName}${stringifyValueForError( + value + )} is not a supported Convex type.`; + } + } + function convexOrUndefinedToJsonInternal(value, originalValue, context) { + if (value === void 0) { + return { $undefined: null }; + } else { + if (originalValue === void 0) { + throw new Error( + `Programming error. Current value is ${stringifyValueForError( + value + )} but original value is undefined` + ); + } + return convexToJsonInternal(value, originalValue, context, false); + } + } + function convexToJson(value) { + return convexToJsonInternal(value, value, "", false); + } + + // src/values/errors.ts + var IDENTIFYING_FIELD = Symbol.for("ConvexError"); + var ConvexError = class extends Error { + name = "ConvexError"; + data; + [IDENTIFYING_FIELD] = true; + constructor(data) { + super(typeof data === "string" ? data : stringifyValueForError(data)); + this.data = data; + } + }; + + // src/browser/logging.ts + var INFO_COLOR = "color:rgb(0, 145, 255)"; + function prefix_for_source(source) { + switch (source) { + case "query": + return "Q"; + case "mutation": + return "M"; + case "action": + return "A"; + case "any": + return "?"; + } + } + var DefaultLogger = class { + _onLogLineFuncs; + _verbose; + constructor(options) { + this._onLogLineFuncs = {}; + this._verbose = options.verbose; + } + addLogLineListener(func) { + let id = Math.random().toString(36).substring(2, 15); + for (let i = 0; i < 10; i++) { + if (this._onLogLineFuncs[id] === void 0) { + break; + } + id = Math.random().toString(36).substring(2, 15); + } + this._onLogLineFuncs[id] = func; + return () => { + delete this._onLogLineFuncs[id]; + }; + } + logVerbose(...args) { + if (this._verbose) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("debug", `${(/* @__PURE__ */ new Date()).toISOString()}`, ...args); + } + } + } + log(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("info", ...args); + } + } + warn(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("warn", ...args); + } + } + error(...args) { + for (const func of Object.values(this._onLogLineFuncs)) { + func("error", ...args); + } + } + }; + function instantiateDefaultLogger(options) { + const logger = new DefaultLogger(options); + logger.addLogLineListener((level, ...args) => { + switch (level) { + case "debug": + console.debug(...args); + break; + case "info": + console.log(...args); + break; + case "warn": + console.warn(...args); + break; + case "error": + console.error(...args); + break; + default: { + level; + console.log(...args); + } + } + }); + return logger; + } + function instantiateNoopLogger(options) { + return new DefaultLogger(options); + } + function logForFunction(logger, type, source, udfPath, message) { + const prefix = prefix_for_source(source); + if (typeof message === "object") { + message = `ConvexError ${JSON.stringify(message.errorData, null, 2)}`; + } + if (type === "info") { + const match = message.match(/^\[.*?\] /); + if (match === null) { + logger.error( + `[CONVEX ${prefix}(${udfPath})] Could not parse console.log` + ); + return; + } + const level = message.slice(1, match[0].length - 2); + const args = message.slice(match[0].length); + logger.log(`%c[CONVEX ${prefix}(${udfPath})] [${level}]`, INFO_COLOR, args); + } else { + logger.error(`[CONVEX ${prefix}(${udfPath})] ${message}`); + } + } + function logFatalError(logger, message) { + const errorMessage = `[CONVEX FATAL ERROR] ${message}`; + logger.error(errorMessage); + return new Error(errorMessage); + } + function createHybridErrorStacktrace(source, udfPath, result) { + const prefix = prefix_for_source(source); + return `[CONVEX ${prefix}(${udfPath})] ${result.errorMessage} + Called by client`; + } + function forwardData(result, error) { + error.data = result.errorData; + return error; + } + + // src/browser/sync/udf_path_utils.ts + function canonicalizeUdfPath(udfPath) { + const pieces = udfPath.split(":"); + let moduleName; + let functionName2; + if (pieces.length === 1) { + moduleName = pieces[0]; + functionName2 = "default"; + } else { + moduleName = pieces.slice(0, pieces.length - 1).join(":"); + functionName2 = pieces[pieces.length - 1]; + } + if (moduleName.endsWith(".js")) { + moduleName = moduleName.slice(0, -3); + } + return `${moduleName}:${functionName2}`; + } + function serializePathAndArgs(udfPath, args) { + return JSON.stringify({ + udfPath: canonicalizeUdfPath(udfPath), + args: convexToJson(args) + }); + } + function serializePaginatedPathAndArgs(udfPath, args, options) { + const { initialNumItems, id } = options; + const result = JSON.stringify({ + type: "paginated", + udfPath: canonicalizeUdfPath(udfPath), + args: convexToJson(args), + options: convexToJson({ initialNumItems, id }) + }); + return result; + } + function serializedQueryTokenIsPaginated(token) { + return JSON.parse(token).type === "paginated"; + } + + // src/browser/sync/local_state.ts + var LocalSyncState = class { + nextQueryId; + querySetVersion; + querySet; + queryIdToToken; + identityVersion; + auth; + outstandingQueriesOlderThanRestart; + outstandingAuthOlderThanRestart; + paused; + pendingQuerySetModifications; + constructor() { + this.nextQueryId = 0; + this.querySetVersion = 0; + this.identityVersion = 0; + this.querySet = /* @__PURE__ */ new Map(); + this.queryIdToToken = /* @__PURE__ */ new Map(); + this.outstandingQueriesOlderThanRestart = /* @__PURE__ */ new Set(); + this.outstandingAuthOlderThanRestart = false; + this.paused = false; + this.pendingQuerySetModifications = /* @__PURE__ */ new Map(); + } + hasSyncedPastLastReconnect() { + return this.outstandingQueriesOlderThanRestart.size === 0 && !this.outstandingAuthOlderThanRestart; + } + markAuthCompletion() { + this.outstandingAuthOlderThanRestart = false; + } + subscribe(udfPath, args, journal, componentPath) { + const canonicalizedUdfPath = canonicalizeUdfPath(udfPath); + const queryToken = serializePathAndArgs(canonicalizedUdfPath, args); + const existingEntry = this.querySet.get(queryToken); + if (existingEntry !== void 0) { + existingEntry.numSubscribers += 1; + return { + queryToken, + modification: null, + unsubscribe: () => this.removeSubscriber(queryToken) + }; + } else { + const queryId = this.nextQueryId++; + const query = { + id: queryId, + canonicalizedUdfPath, + args, + numSubscribers: 1, + journal, + componentPath + }; + this.querySet.set(queryToken, query); + this.queryIdToToken.set(queryId, queryToken); + const baseVersion = this.querySetVersion; + const newVersion = this.querySetVersion + 1; + const add = { + type: "Add", + queryId, + udfPath: canonicalizedUdfPath, + args: [convexToJson(args)], + journal, + componentPath + }; + if (this.paused) { + this.pendingQuerySetModifications.set(queryId, add); + } else { + this.querySetVersion = newVersion; + } + const modification = { + type: "ModifyQuerySet", + baseVersion, + newVersion, + modifications: [add] + }; + return { + queryToken, + modification, + unsubscribe: () => this.removeSubscriber(queryToken) + }; + } + } + transition(transition) { + for (const modification of transition.modifications) { + switch (modification.type) { + case "QueryUpdated": + case "QueryFailed": { + this.outstandingQueriesOlderThanRestart.delete(modification.queryId); + const journal = modification.journal; + if (journal !== void 0) { + const queryToken = this.queryIdToToken.get(modification.queryId); + if (queryToken !== void 0) { + this.querySet.get(queryToken).journal = journal; + } + } + break; + } + case "QueryRemoved": { + this.outstandingQueriesOlderThanRestart.delete(modification.queryId); + break; + } + default: { + modification; + throw new Error(`Invalid modification ${modification.type}`); + } + } + } + } + queryId(udfPath, args) { + const canonicalizedUdfPath = canonicalizeUdfPath(udfPath); + const queryToken = serializePathAndArgs(canonicalizedUdfPath, args); + const existingEntry = this.querySet.get(queryToken); + if (existingEntry !== void 0) { + return existingEntry.id; + } + return null; + } + isCurrentOrNewerAuthVersion(version2) { + return version2 >= this.identityVersion; + } + getAuth() { + return this.auth; + } + setAuth(value) { + this.auth = { + tokenType: "User", + value + }; + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + baseVersion, + ...this.auth + }; + } + setAdminAuth(value, actingAs) { + const auth = { + tokenType: "Admin", + value, + impersonating: actingAs + }; + this.auth = auth; + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + baseVersion, + ...auth + }; + } + clearAuth() { + this.auth = void 0; + this.markAuthCompletion(); + const baseVersion = this.identityVersion; + if (!this.paused) { + this.identityVersion = baseVersion + 1; + } + return { + type: "Authenticate", + tokenType: "None", + baseVersion + }; + } + hasAuth() { + return !!this.auth; + } + isNewAuth(value) { + return this.auth?.value !== value; + } + queryPath(queryId) { + const pathAndArgs = this.queryIdToToken.get(queryId); + if (pathAndArgs) { + return this.querySet.get(pathAndArgs).canonicalizedUdfPath; + } + return null; + } + queryArgs(queryId) { + const pathAndArgs = this.queryIdToToken.get(queryId); + if (pathAndArgs) { + return this.querySet.get(pathAndArgs).args; + } + return null; + } + queryToken(queryId) { + return this.queryIdToToken.get(queryId) ?? null; + } + queryJournal(queryToken) { + return this.querySet.get(queryToken)?.journal; + } + restart() { + this.unpause(); + this.outstandingQueriesOlderThanRestart.clear(); + const modifications = []; + for (const localQuery of this.querySet.values()) { + const add = { + type: "Add", + queryId: localQuery.id, + udfPath: localQuery.canonicalizedUdfPath, + args: [convexToJson(localQuery.args)], + journal: localQuery.journal, + componentPath: localQuery.componentPath + }; + modifications.push(add); + this.outstandingQueriesOlderThanRestart.add(localQuery.id); + } + this.querySetVersion = 1; + const querySet = { + type: "ModifyQuerySet", + baseVersion: 0, + newVersion: 1, + modifications + }; + if (!this.auth) { + this.identityVersion = 0; + return [querySet, void 0]; + } + this.outstandingAuthOlderThanRestart = true; + const authenticate = { + type: "Authenticate", + baseVersion: 0, + ...this.auth + }; + this.identityVersion = 1; + return [querySet, authenticate]; + } + pause() { + this.paused = true; + } + resume() { + const querySet = this.pendingQuerySetModifications.size > 0 ? { + type: "ModifyQuerySet", + baseVersion: this.querySetVersion, + newVersion: ++this.querySetVersion, + modifications: Array.from( + this.pendingQuerySetModifications.values() + ) + } : void 0; + const authenticate = this.auth !== void 0 ? { + type: "Authenticate", + baseVersion: this.identityVersion++, + ...this.auth + } : void 0; + this.unpause(); + return [querySet, authenticate]; + } + unpause() { + this.paused = false; + this.pendingQuerySetModifications.clear(); + } + removeSubscriber(queryToken) { + const localQuery = this.querySet.get(queryToken); + if (localQuery.numSubscribers > 1) { + localQuery.numSubscribers -= 1; + return null; + } else { + this.querySet.delete(queryToken); + this.queryIdToToken.delete(localQuery.id); + this.outstandingQueriesOlderThanRestart.delete(localQuery.id); + const baseVersion = this.querySetVersion; + const newVersion = this.querySetVersion + 1; + const remove = { + type: "Remove", + queryId: localQuery.id + }; + if (this.paused) { + if (this.pendingQuerySetModifications.has(localQuery.id)) { + this.pendingQuerySetModifications.delete(localQuery.id); + } else { + this.pendingQuerySetModifications.set(localQuery.id, remove); + } + } else { + this.querySetVersion = newVersion; + } + return { + type: "ModifyQuerySet", + baseVersion, + newVersion, + modifications: [remove] + }; + } + } + }; + + // src/browser/sync/request_manager.ts + var RequestManager = class { + constructor(logger, markConnectionStateDirty) { + this.logger = logger; + this.markConnectionStateDirty = markConnectionStateDirty; + this.inflightRequests = /* @__PURE__ */ new Map(); + this.requestsOlderThanRestart = /* @__PURE__ */ new Set(); + } + inflightRequests; + requestsOlderThanRestart; + inflightMutationsCount = 0; + inflightActionsCount = 0; + request(message, sent) { + const result = new Promise((resolve) => { + const status = sent ? "Requested" : "NotSent"; + this.inflightRequests.set(message.requestId, { + message, + status: { status, requestedAt: /* @__PURE__ */ new Date(), onResult: resolve } + }); + if (message.type === "Mutation") { + this.inflightMutationsCount++; + } else if (message.type === "Action") { + this.inflightActionsCount++; + } + }); + this.markConnectionStateDirty(); + return result; + } + /** + * Update the state after receiving a response. + * + * @returns A RequestId if the request is complete and its optimistic update + * can be dropped, null otherwise. + */ + onResponse(response) { + const requestInfo = this.inflightRequests.get(response.requestId); + if (requestInfo === void 0) { + return null; + } + if (requestInfo.status.status === "Completed") { + return null; + } + const udfType = requestInfo.message.type === "Mutation" ? "mutation" : "action"; + const udfPath = requestInfo.message.udfPath; + for (const line of response.logLines) { + logForFunction(this.logger, "info", udfType, udfPath, line); + } + const status = requestInfo.status; + let result; + let onResolve; + if (response.success) { + result = { + success: true, + logLines: response.logLines, + value: jsonToConvex(response.result) + }; + onResolve = () => status.onResult(result); + } else { + const errorMessage = response.result; + const { errorData } = response; + logForFunction(this.logger, "error", udfType, udfPath, errorMessage); + result = { + success: false, + errorMessage, + errorData: errorData !== void 0 ? jsonToConvex(errorData) : void 0, + logLines: response.logLines + }; + onResolve = () => status.onResult(result); + } + if (response.type === "ActionResponse" || !response.success) { + onResolve(); + this.inflightRequests.delete(response.requestId); + this.requestsOlderThanRestart.delete(response.requestId); + if (requestInfo.message.type === "Action") { + this.inflightActionsCount--; + } else if (requestInfo.message.type === "Mutation") { + this.inflightMutationsCount--; + } + this.markConnectionStateDirty(); + return { requestId: response.requestId, result }; + } + requestInfo.status = { + status: "Completed", + result, + ts: response.ts, + onResolve + }; + return null; + } + // Remove and returns completed requests. + removeCompleted(ts) { + const completeRequests = /* @__PURE__ */ new Map(); + for (const [requestId, requestInfo] of this.inflightRequests.entries()) { + const status = requestInfo.status; + if (status.status === "Completed" && status.ts.lessThanOrEqual(ts)) { + status.onResolve(); + completeRequests.set(requestId, status.result); + if (requestInfo.message.type === "Mutation") { + this.inflightMutationsCount--; + } else if (requestInfo.message.type === "Action") { + this.inflightActionsCount--; + } + this.inflightRequests.delete(requestId); + this.requestsOlderThanRestart.delete(requestId); + } + } + if (completeRequests.size > 0) { + this.markConnectionStateDirty(); + } + return completeRequests; + } + restart() { + this.requestsOlderThanRestart = new Set(this.inflightRequests.keys()); + const allMessages = []; + for (const [requestId, value] of this.inflightRequests) { + if (value.status.status === "NotSent") { + value.status.status = "Requested"; + allMessages.push(value.message); + continue; + } + if (value.message.type === "Mutation") { + allMessages.push(value.message); + } else if (value.message.type === "Action") { + this.inflightRequests.delete(requestId); + this.requestsOlderThanRestart.delete(requestId); + this.inflightActionsCount--; + if (value.status.status === "Completed") { + throw new Error("Action should never be in 'Completed' state"); + } + value.status.onResult({ + success: false, + errorMessage: "Connection lost while action was in flight", + logLines: [] + }); + } + } + this.markConnectionStateDirty(); + return allMessages; + } + resume() { + const allMessages = []; + for (const [, value] of this.inflightRequests) { + if (value.status.status === "NotSent") { + value.status.status = "Requested"; + allMessages.push(value.message); + continue; + } + } + return allMessages; + } + /** + * @returns true if there are any requests that have been requested but have + * not be completed yet. + */ + hasIncompleteRequests() { + for (const requestInfo of this.inflightRequests.values()) { + if (requestInfo.status.status === "Requested") { + return true; + } + } + return false; + } + /** + * @returns true if there are any inflight requests, including ones that have + * completed on the server, but have not been applied. + */ + hasInflightRequests() { + return this.inflightRequests.size > 0; + } + /** + * @returns true if there are any inflight requests, that have been hanging around + * since prior to the most recent restart. + */ + hasSyncedPastLastReconnect() { + return this.requestsOlderThanRestart.size === 0; + } + timeOfOldestInflightRequest() { + if (this.inflightRequests.size === 0) { + return null; + } + let oldestInflightRequest = Date.now(); + for (const request of this.inflightRequests.values()) { + if (request.status.status !== "Completed") { + if (request.status.requestedAt.getTime() < oldestInflightRequest) { + oldestInflightRequest = request.status.requestedAt.getTime(); + } + } + } + return new Date(oldestInflightRequest); + } + /** + * @returns The number of mutations currently in flight. + */ + inflightMutations() { + return this.inflightMutationsCount; + } + /** + * @returns The number of actions currently in flight. + */ + inflightActions() { + return this.inflightActionsCount; + } + }; + + // src/server/functionName.ts + var functionName = Symbol.for("functionName"); + + // src/server/components/paths.ts + var toReferencePath = Symbol.for("toReferencePath"); + function extractReferencePath(reference) { + return reference[toReferencePath] ?? null; + } + function isFunctionHandle(s) { + return s.startsWith("function://"); + } + function getFunctionAddress(functionReference) { + let functionAddress; + if (typeof functionReference === "string") { + if (isFunctionHandle(functionReference)) { + functionAddress = { functionHandle: functionReference }; + } else { + functionAddress = { name: functionReference }; + } + } else if (functionReference[functionName]) { + functionAddress = { name: functionReference[functionName] }; + } else { + const referencePath = extractReferencePath(functionReference); + if (!referencePath) { + throw new Error(`${functionReference} is not a functionReference`); + } + functionAddress = { reference: referencePath }; + } + return functionAddress; + } + + // src/server/api.ts + function getFunctionName(functionReference) { + const address = getFunctionAddress(functionReference); + if (address.name === void 0) { + if (address.functionHandle !== void 0) { + throw new Error( + `Expected function reference like "api.file.func" or "internal.file.func", but received function handle ${address.functionHandle}` + ); + } else if (address.reference !== void 0) { + throw new Error( + `Expected function reference in the current component like "api.file.func" or "internal.file.func", but received reference ${address.reference}` + ); + } + throw new Error( + `Expected function reference like "api.file.func" or "internal.file.func", but received ${JSON.stringify(address)}` + ); + } + if (typeof functionReference === "string") return functionReference; + const name = functionReference[functionName]; + if (!name) { + throw new Error(`${functionReference} is not a functionReference`); + } + return name; + } + function createApi(pathParts = []) { + const handler = { + get(_, prop) { + if (typeof prop === "string") { + const newParts = [...pathParts, prop]; + return createApi(newParts); + } else if (prop === functionName) { + if (pathParts.length < 2) { + const found = ["api", ...pathParts].join("."); + throw new Error( + `API path is expected to be of the form \`api.moduleName.functionName\`. Found: \`${found}\`` + ); + } + const path = pathParts.slice(0, -1).join("/"); + const exportName = pathParts[pathParts.length - 1]; + if (exportName === "default") { + return path; + } else { + return path + ":" + exportName; + } + } else if (prop === Symbol.toStringTag) { + return "FunctionReference"; + } else { + return void 0; + } + } + }; + return new Proxy({}, handler); + } + var anyApi = createApi(); + + // src/browser/sync/optimistic_updates_impl.ts + var OptimisticLocalStoreImpl = class _OptimisticLocalStoreImpl { + // A references of the query results in OptimisticQueryResults + queryResults; + // All of the queries modified by this class + modifiedQueries; + constructor(queryResults) { + this.queryResults = queryResults; + this.modifiedQueries = []; + } + getQuery(query, ...args) { + const queryArgs = parseArgs(args[0]); + const name = getFunctionName(query); + const queryResult = this.queryResults.get( + serializePathAndArgs(name, queryArgs) + ); + if (queryResult === void 0) { + return void 0; + } + return _OptimisticLocalStoreImpl.queryValue(queryResult.result); + } + getAllQueries(query) { + const queriesWithName = []; + const name = getFunctionName(query); + for (const queryResult of this.queryResults.values()) { + if (queryResult.udfPath === canonicalizeUdfPath(name)) { + queriesWithName.push({ + args: queryResult.args, + value: _OptimisticLocalStoreImpl.queryValue(queryResult.result) + }); + } + } + return queriesWithName; + } + setQuery(queryReference, args, value) { + const queryArgs = parseArgs(args); + const name = getFunctionName(queryReference); + const queryToken = serializePathAndArgs(name, queryArgs); + let result; + if (value === void 0) { + result = void 0; + } else { + result = { + success: true, + value, + // It's an optimistic update, so there are no function logs to show. + logLines: [] + }; + } + const query = { + udfPath: name, + args: queryArgs, + result + }; + this.queryResults.set(queryToken, query); + this.modifiedQueries.push(queryToken); + } + static queryValue(result) { + if (result === void 0) { + return void 0; + } else if (result.success) { + return result.value; + } else { + return void 0; + } + } + }; + var OptimisticQueryResults = class { + queryResults; + optimisticUpdates; + constructor() { + this.queryResults = /* @__PURE__ */ new Map(); + this.optimisticUpdates = []; + } + /** + * Apply all optimistic updates on top of server query results + */ + ingestQueryResultsFromServer(serverQueryResults, optimisticUpdatesToDrop) { + this.optimisticUpdates = this.optimisticUpdates.filter((updateAndId) => { + return !optimisticUpdatesToDrop.has(updateAndId.mutationId); + }); + const oldQueryResults = this.queryResults; + this.queryResults = new Map(serverQueryResults); + const localStore = new OptimisticLocalStoreImpl(this.queryResults); + for (const updateAndId of this.optimisticUpdates) { + updateAndId.update(localStore); + } + const changedQueries = []; + for (const [queryToken, query] of this.queryResults) { + const oldQuery = oldQueryResults.get(queryToken); + if (oldQuery === void 0 || oldQuery.result !== query.result) { + changedQueries.push(queryToken); + } + } + return changedQueries; + } + applyOptimisticUpdate(update, mutationId) { + this.optimisticUpdates.push({ + update, + mutationId + }); + const localStore = new OptimisticLocalStoreImpl(this.queryResults); + update(localStore); + return localStore.modifiedQueries; + } + /** + * "Raw" with respect to errors vs values, but query results still have + * optimistic updates applied. + * + * @internal + */ + rawQueryResult(queryToken) { + const query = this.queryResults.get(queryToken); + if (query === void 0) { + return void 0; + } + return query.result; + } + queryResult(queryToken) { + const query = this.queryResults.get(queryToken); + if (query === void 0) { + return void 0; + } + const result = query.result; + if (result === void 0) { + return void 0; + } else if (result.success) { + return result.value; + } else { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError( + createHybridErrorStacktrace("query", query.udfPath, result) + ) + ); + } + throw new Error( + createHybridErrorStacktrace("query", query.udfPath, result) + ); + } + } + hasQueryResult(queryToken) { + return this.queryResults.get(queryToken) !== void 0; + } + /** + * @internal + */ + queryLogs(queryToken) { + const query = this.queryResults.get(queryToken); + return query?.result?.logLines; + } + }; + + // src/vendor/long.ts + var Long = class _Long { + low; + high; + __isUnsignedLong__; + static isLong(obj) { + return (obj && obj.__isUnsignedLong__) === true; + } + constructor(low, high) { + this.low = low | 0; + this.high = high | 0; + this.__isUnsignedLong__ = true; + } + // prettier-ignore + static fromBytesLE(bytes) { + return new _Long( + bytes[0] | bytes[1] << 8 | bytes[2] << 16 | bytes[3] << 24, + bytes[4] | bytes[5] << 8 | bytes[6] << 16 | bytes[7] << 24 + ); + } + // prettier-ignore + toBytesLE() { + const hi = this.high; + const lo = this.low; + return [ + lo & 255, + lo >>> 8 & 255, + lo >>> 16 & 255, + lo >>> 24, + hi & 255, + hi >>> 8 & 255, + hi >>> 16 & 255, + hi >>> 24 + ]; + } + static fromNumber(value) { + if (isNaN(value)) return UZERO; + if (value < 0) return UZERO; + if (value >= TWO_PWR_64_DBL) return MAX_UNSIGNED_VALUE; + return new _Long(value % TWO_PWR_32_DBL | 0, value / TWO_PWR_32_DBL | 0); + } + toString() { + return (BigInt(this.high) * BigInt(TWO_PWR_32_DBL) + BigInt(this.low)).toString(); + } + equals(other) { + if (!_Long.isLong(other)) other = _Long.fromValue(other); + if (this.high >>> 31 === 1 && other.high >>> 31 === 1) return false; + return this.high === other.high && this.low === other.low; + } + notEquals(other) { + return !this.equals(other); + } + comp(other) { + if (!_Long.isLong(other)) other = _Long.fromValue(other); + if (this.equals(other)) return 0; + return other.high >>> 0 > this.high >>> 0 || other.high === this.high && other.low >>> 0 > this.low >>> 0 ? -1 : 1; + } + lessThanOrEqual(other) { + return this.comp( + /* validates */ + other + ) <= 0; + } + static fromValue(val) { + if (typeof val === "number") return _Long.fromNumber(val); + return new _Long(val.low, val.high); + } + }; + var UZERO = new Long(0, 0); + var TWO_PWR_16_DBL = 1 << 16; + var TWO_PWR_32_DBL = TWO_PWR_16_DBL * TWO_PWR_16_DBL; + var TWO_PWR_64_DBL = TWO_PWR_32_DBL * TWO_PWR_32_DBL; + var MAX_UNSIGNED_VALUE = new Long(4294967295 | 0, 4294967295 | 0); + + // src/browser/sync/remote_query_set.ts + var RemoteQuerySet = class { + version; + remoteQuerySet; + queryPath; + logger; + constructor(queryPath, logger) { + this.version = { querySet: 0, ts: Long.fromNumber(0), identity: 0 }; + this.remoteQuerySet = /* @__PURE__ */ new Map(); + this.queryPath = queryPath; + this.logger = logger; + } + transition(transition) { + const start = transition.startVersion; + if (this.version.querySet !== start.querySet || this.version.ts.notEquals(start.ts) || this.version.identity !== start.identity) { + throw new Error( + `Invalid start version: ${start.ts.toString()}:${start.querySet}:${start.identity}, transitioning from ${this.version.ts.toString()}:${this.version.querySet}:${this.version.identity}` + ); + } + for (const modification of transition.modifications) { + switch (modification.type) { + case "QueryUpdated": { + const queryPath = this.queryPath(modification.queryId); + if (queryPath) { + for (const line of modification.logLines) { + logForFunction(this.logger, "info", "query", queryPath, line); + } + } + const value = jsonToConvex(modification.value ?? null); + this.remoteQuerySet.set(modification.queryId, { + success: true, + value, + logLines: modification.logLines + }); + break; + } + case "QueryFailed": { + const queryPath = this.queryPath(modification.queryId); + if (queryPath) { + for (const line of modification.logLines) { + logForFunction(this.logger, "info", "query", queryPath, line); + } + } + const { errorData } = modification; + this.remoteQuerySet.set(modification.queryId, { + success: false, + errorMessage: modification.errorMessage, + errorData: errorData !== void 0 ? jsonToConvex(errorData) : void 0, + logLines: modification.logLines + }); + break; + } + case "QueryRemoved": { + this.remoteQuerySet.delete(modification.queryId); + break; + } + default: { + modification; + throw new Error(`Invalid modification ${modification.type}`); + } + } + } + this.version = transition.endVersion; + } + remoteQueryResults() { + return this.remoteQuerySet; + } + timestamp() { + return this.version.ts; + } + }; + + // src/browser/sync/protocol.ts + function u64ToLong(encoded) { + const integerBytes = base64_exports.toByteArray(encoded); + return Long.fromBytesLE(Array.from(integerBytes)); + } + function longToU64(raw) { + const integerBytes = new Uint8Array(raw.toBytesLE()); + return base64_exports.fromByteArray(integerBytes); + } + function parseServerMessage(encoded) { + switch (encoded.type) { + case "FatalError": + case "AuthError": + case "ActionResponse": + case "TransitionChunk": + case "Ping": { + return { ...encoded }; + } + case "MutationResponse": { + if (encoded.success) { + return { ...encoded, ts: u64ToLong(encoded.ts) }; + } else { + return { ...encoded }; + } + } + case "Transition": { + return { + ...encoded, + startVersion: { + ...encoded.startVersion, + ts: u64ToLong(encoded.startVersion.ts) + }, + endVersion: { + ...encoded.endVersion, + ts: u64ToLong(encoded.endVersion.ts) + } + }; + } + default: { + encoded; + } + } + return void 0; + } + function encodeClientMessage(message) { + switch (message.type) { + case "Authenticate": + case "ModifyQuerySet": + case "Mutation": + case "Action": + case "Event": { + return { ...message }; + } + case "Connect": { + if (message.maxObservedTimestamp !== void 0) { + return { + ...message, + maxObservedTimestamp: longToU64(message.maxObservedTimestamp) + }; + } else { + return { ...message, maxObservedTimestamp: void 0 }; + } + } + default: { + message; + } + } + return void 0; + } + + // src/browser/sync/web_socket_manager.ts + var CLOSE_NORMAL = 1e3; + var CLOSE_GOING_AWAY = 1001; + var CLOSE_NO_STATUS = 1005; + var CLOSE_NOT_FOUND = 4040; + var firstTime; + function monotonicMillis() { + if (firstTime === void 0) { + firstTime = Date.now(); + } + if (typeof performance === "undefined" || !performance.now) { + return Date.now(); + } + return Math.round(firstTime + performance.now()); + } + function prettyNow() { + return `t=${Math.round((monotonicMillis() - firstTime) / 100) / 10}s`; + } + var serverDisconnectErrors = { + // A known error, e.g. during a restart or push + InternalServerError: { timeout: 1e3 }, + // ErrorMetadata::overloaded() messages that we realy should back off + SubscriptionsWorkerFullError: { timeout: 3e3 }, + TooManyConcurrentRequests: { timeout: 3e3 }, + CommitterFullError: { timeout: 3e3 }, + AwsTooManyRequestsException: { timeout: 3e3 }, + ExecuteFullError: { timeout: 3e3 }, + SystemTimeoutError: { timeout: 3e3 }, + ExpiredInQueue: { timeout: 3e3 }, + // ErrorMetadata::feature_temporarily_unavailable() that typically indicate a deploy just happened + VectorIndexesUnavailable: { timeout: 1e3 }, + SearchIndexesUnavailable: { timeout: 1e3 }, + TableSummariesUnavailable: { timeout: 1e3 }, + // More ErrorMetadata::overloaded() + VectorIndexTooLarge: { timeout: 3e3 }, + SearchIndexTooLarge: { timeout: 3e3 }, + TooManyWritesInTimePeriod: { timeout: 3e3 } + }; + function classifyDisconnectError(s) { + if (s === void 0) return "Unknown"; + for (const prefix of Object.keys( + serverDisconnectErrors + )) { + if (s.startsWith(prefix)) { + return prefix; + } + } + return "Unknown"; + } + var WebSocketManager = class { + constructor(uri, callbacks, webSocketConstructor, logger, markConnectionStateDirty, debug) { + this.markConnectionStateDirty = markConnectionStateDirty; + this.debug = debug; + this.webSocketConstructor = webSocketConstructor; + this.socket = { state: "disconnected" }; + this.connectionCount = 0; + this.lastCloseReason = "InitialConnect"; + this.defaultInitialBackoff = 1e3; + this.maxBackoff = 16e3; + this.retries = 0; + this.serverInactivityThreshold = 6e4; + this.reconnectDueToServerInactivityTimeout = null; + this.uri = uri; + this.onOpen = callbacks.onOpen; + this.onResume = callbacks.onResume; + this.onMessage = callbacks.onMessage; + this.onServerDisconnectError = callbacks.onServerDisconnectError; + this.logger = logger; + this.setupNetworkListener(); + this.connect(); + } + socket; + connectionCount; + _hasEverConnected = false; + lastCloseReason; + // State for assembling the split-up Transition currently being received. + transitionChunkBuffer = null; + /** Upon HTTPS/WSS failure, the first jittered backoff duration, in ms. */ + defaultInitialBackoff; + /** We backoff exponentially, but we need to cap that--this is the jittered max. */ + maxBackoff; + /** How many times have we failed consecutively? */ + retries; + /** How long before lack of server response causes us to initiate a reconnect, + * in ms */ + serverInactivityThreshold; + reconnectDueToServerInactivityTimeout; + /** Scheduled reconnect state: timeout handle and timing info */ + scheduledReconnect = null; + networkOnlineHandler = null; + /** Pending event to send after reconnecting due to network recovery */ + pendingNetworkRecoveryInfo = null; + uri; + onOpen; + onResume; + onMessage; + webSocketConstructor; + logger; + onServerDisconnectError; + setSocketState(state) { + this.socket = state; + this._logVerbose( + `socket state changed: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : void 0}` + ); + this.markConnectionStateDirty(); + } + setupNetworkListener() { + if (typeof window === "undefined" || typeof window.addEventListener !== "function") { + return; + } + if (this.networkOnlineHandler !== null) { + return; + } + this.networkOnlineHandler = () => { + this._logVerbose("network online event detected"); + this.tryReconnectImmediately(); + }; + window.addEventListener("online", this.networkOnlineHandler); + this._logVerbose("network online event listener registered"); + } + cleanupNetworkListener() { + if (this.networkOnlineHandler && typeof window !== "undefined" && typeof window.removeEventListener === "function") { + window.removeEventListener("online", this.networkOnlineHandler); + this.networkOnlineHandler = null; + this._logVerbose("network online event listener removed"); + } + } + assembleTransition(chunk) { + if (chunk.partNumber < 0 || chunk.partNumber >= chunk.totalParts || chunk.totalParts === 0 || this.transitionChunkBuffer && (this.transitionChunkBuffer.totalParts !== chunk.totalParts || this.transitionChunkBuffer.transitionId !== chunk.transitionId)) { + this.transitionChunkBuffer = null; + throw new Error("Invalid TransitionChunk"); + } + if (this.transitionChunkBuffer === null) { + this.transitionChunkBuffer = { + chunks: [], + totalParts: chunk.totalParts, + transitionId: chunk.transitionId + }; + } + if (chunk.partNumber !== this.transitionChunkBuffer.chunks.length) { + const expectedLength = this.transitionChunkBuffer.chunks.length; + this.transitionChunkBuffer = null; + throw new Error( + `TransitionChunk received out of order: expected part ${expectedLength}, got ${chunk.partNumber}` + ); + } + this.transitionChunkBuffer.chunks.push(chunk.chunk); + if (this.transitionChunkBuffer.chunks.length === chunk.totalParts) { + const fullJson = this.transitionChunkBuffer.chunks.join(""); + this.transitionChunkBuffer = null; + const transition = parseServerMessage(JSON.parse(fullJson)); + if (transition.type !== "Transition") { + throw new Error( + `Expected Transition, got ${transition.type} after assembling chunks` + ); + } + return transition; + } + return null; + } + connect() { + if (this.socket.state === "terminated") { + return; + } + if (this.socket.state !== "disconnected" && this.socket.state !== "stopped") { + throw new Error( + "Didn't start connection from disconnected state: " + this.socket.state + ); + } + const ws = new this.webSocketConstructor(this.uri); + this._logVerbose("constructed WebSocket"); + this.setSocketState({ + state: "connecting", + ws, + paused: "no" + }); + this.resetServerInactivityTimeout(); + ws.onopen = () => { + this.logger.logVerbose("begin ws.onopen"); + if (this.socket.state !== "connecting") { + throw new Error("onopen called with socket not in connecting state"); + } + this.setSocketState({ + state: "ready", + ws, + paused: this.socket.paused === "yes" ? "uninitialized" : "no" + }); + this.resetServerInactivityTimeout(); + if (this.socket.paused === "no") { + this._hasEverConnected = true; + this.onOpen({ + connectionCount: this.connectionCount, + lastCloseReason: this.lastCloseReason, + clientTs: monotonicMillis() + }); + } + if (this.lastCloseReason !== "InitialConnect") { + if (this.lastCloseReason) { + this.logger.log( + "WebSocket reconnected at", + prettyNow(), + "after disconnect due to", + this.lastCloseReason + ); + } else { + this.logger.log("WebSocket reconnected at", prettyNow()); + } + } + this.connectionCount += 1; + this.lastCloseReason = null; + if (this.pendingNetworkRecoveryInfo !== null) { + const { timeSavedMs } = this.pendingNetworkRecoveryInfo; + this.pendingNetworkRecoveryInfo = null; + this.sendMessage({ + type: "Event", + eventType: "NetworkRecoveryReconnect", + event: { timeSavedMs } + }); + this.logger.log( + `Network recovery reconnect saved ~${Math.round(timeSavedMs / 1e3)}s of waiting` + ); + } + }; + ws.onerror = (error) => { + this.transitionChunkBuffer = null; + const message = error.message; + if (message) { + this.logger.log(`WebSocket error message: ${message}`); + } + }; + ws.onmessage = (message) => { + this.resetServerInactivityTimeout(); + const messageLength = message.data.length; + let serverMessage = parseServerMessage(JSON.parse(message.data)); + this._logVerbose(`received ws message with type ${serverMessage.type}`); + if (serverMessage.type === "Ping") { + return; + } + if (serverMessage.type === "TransitionChunk") { + const transition = this.assembleTransition(serverMessage); + if (!transition) { + return; + } + serverMessage = transition; + this._logVerbose( + `assembled full ws message of type ${serverMessage.type}` + ); + } + if (this.transitionChunkBuffer !== null) { + this.transitionChunkBuffer = null; + this.logger.log( + `Received unexpected ${serverMessage.type} while buffering TransitionChunks` + ); + } + if (serverMessage.type === "Transition") { + this.reportLargeTransition({ + messageLength, + transition: serverMessage + }); + } + const response = this.onMessage(serverMessage); + if (response.hasSyncedPastLastReconnect) { + this.retries = 0; + this.markConnectionStateDirty(); + } + }; + ws.onclose = (event) => { + this._logVerbose("begin ws.onclose"); + this.transitionChunkBuffer = null; + if (this.lastCloseReason === null) { + this.lastCloseReason = event.reason || `closed with code ${event.code}`; + } + if (event.code !== CLOSE_NORMAL && event.code !== CLOSE_GOING_AWAY && // This commonly gets fired on mobile apps when the app is backgrounded + event.code !== CLOSE_NO_STATUS && event.code !== CLOSE_NOT_FOUND) { + let msg = `WebSocket closed with code ${event.code}`; + if (event.reason) { + msg += `: ${event.reason}`; + } + this.logger.log(msg); + if (this.onServerDisconnectError && event.reason) { + this.onServerDisconnectError(msg); + } + } + const reason = classifyDisconnectError(event.reason); + this.scheduleReconnect(reason); + return; + }; + } + /** + * @returns The state of the {@link Socket}. + */ + socketState() { + return this.socket.state; + } + /** + * @param message - A ClientMessage to send. + * @returns Whether the message (might have been) sent. + */ + sendMessage(message) { + const messageForLog = { + type: message.type, + ...message.type === "Authenticate" && message.tokenType === "User" ? { + value: `...${message.value.slice(-7)}` + } : {} + }; + if (this.socket.state === "ready" && this.socket.paused === "no") { + const encodedMessage = encodeClientMessage(message); + const request = JSON.stringify(encodedMessage); + let sent = false; + try { + this.socket.ws.send(request); + sent = true; + } catch (error) { + this.logger.log( + `Failed to send message on WebSocket, reconnecting: ${error}` + ); + this.closeAndReconnect("FailedToSendMessage"); + } + this._logVerbose( + `${sent ? "sent" : "failed to send"} message with type ${message.type}: ${JSON.stringify( + messageForLog + )}` + ); + return true; + } + this._logVerbose( + `message not sent (socket state: ${this.socket.state}, paused: ${"paused" in this.socket ? this.socket.paused : void 0}): ${JSON.stringify( + messageForLog + )}` + ); + return false; + } + resetServerInactivityTimeout() { + if (this.socket.state === "terminated") { + return; + } + if (this.reconnectDueToServerInactivityTimeout !== null) { + clearTimeout(this.reconnectDueToServerInactivityTimeout); + this.reconnectDueToServerInactivityTimeout = null; + } + this.reconnectDueToServerInactivityTimeout = setTimeout(() => { + this.closeAndReconnect("InactiveServer"); + }, this.serverInactivityThreshold); + } + scheduleReconnect(reason) { + if (this.scheduledReconnect) { + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + } + this.socket = { state: "disconnected" }; + const backoff = this.nextBackoff(reason); + this.markConnectionStateDirty(); + this.logger.log(`Attempting reconnect in ${Math.round(backoff)}ms`); + const scheduledAt = monotonicMillis(); + const timeoutId = setTimeout(() => { + if (this.scheduledReconnect?.timeout === timeoutId) { + this.scheduledReconnect = null; + this.connect(); + } + }, backoff); + this.scheduledReconnect = { + timeout: timeoutId, + scheduledAt, + backoffMs: backoff + }; + } + /** + * Close the WebSocket and schedule a reconnect. + * + * This should be used when we hit an error and would like to restart the session. + */ + closeAndReconnect(closeReason) { + this._logVerbose(`begin closeAndReconnect with reason ${closeReason}`); + switch (this.socket.state) { + case "disconnected": + case "terminated": + case "stopped": + return; + case "connecting": + case "ready": { + this.lastCloseReason = closeReason; + void this.close(); + this.scheduleReconnect("client"); + return; + } + default: { + this.socket; + } + } + } + /** + * Close the WebSocket, being careful to clear the onclose handler to avoid re-entrant + * calls. Use this instead of directly calling `ws.close()` + * + * It is the callers responsibility to update the state after this method is called so that the + * closed socket is not accessible or used again after this method is called + */ + close() { + this.transitionChunkBuffer = null; + switch (this.socket.state) { + case "disconnected": + case "terminated": + case "stopped": + return Promise.resolve(); + case "connecting": { + const ws = this.socket.ws; + ws.onmessage = (_message) => { + this._logVerbose("Ignoring message received after close"); + }; + return new Promise((r) => { + ws.onclose = () => { + this._logVerbose("Closed after connecting"); + r(); + }; + ws.onopen = () => { + this._logVerbose("Opened after connecting"); + ws.close(); + }; + }); + } + case "ready": { + this._logVerbose("ws.close called"); + const ws = this.socket.ws; + ws.onmessage = (_message) => { + this._logVerbose("Ignoring message received after close"); + }; + const result = new Promise((r) => { + ws.onclose = () => { + r(); + }; + }); + ws.close(); + return result; + } + default: { + this.socket; + return Promise.resolve(); + } + } + } + /** + * Close the WebSocket and do not reconnect. + * @returns A Promise that resolves when the WebSocket `onClose` callback is called. + */ + terminate() { + if (this.reconnectDueToServerInactivityTimeout) { + clearTimeout(this.reconnectDueToServerInactivityTimeout); + } + if (this.scheduledReconnect) { + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + } + this.cleanupNetworkListener(); + switch (this.socket.state) { + case "terminated": + case "stopped": + case "disconnected": + case "connecting": + case "ready": { + const result = this.close(); + this.setSocketState({ state: "terminated" }); + return result; + } + default: { + this.socket; + throw new Error( + `Invalid websocket state: ${this.socket.state}` + ); + } + } + } + stop() { + switch (this.socket.state) { + case "terminated": + return Promise.resolve(); + case "connecting": + case "stopped": + case "disconnected": + case "ready": { + this.cleanupNetworkListener(); + const result = this.close(); + this.socket = { state: "stopped" }; + return result; + } + default: { + this.socket; + return Promise.resolve(); + } + } + } + /** + * Create a new WebSocket after a previous `stop()`, unless `terminate()` was + * called before. + */ + tryRestart() { + switch (this.socket.state) { + case "stopped": + break; + case "terminated": + case "connecting": + case "ready": + case "disconnected": + this.logger.logVerbose("Restart called without stopping first"); + return; + default: { + this.socket; + } + } + this.setupNetworkListener(); + this.connect(); + } + pause() { + switch (this.socket.state) { + case "disconnected": + case "stopped": + case "terminated": + return; + case "connecting": + case "ready": { + this.socket = { ...this.socket, paused: "yes" }; + return; + } + default: { + this.socket; + return; + } + } + } + /** + * Try to reconnect immediately, canceling any scheduled reconnect. + * This is useful when detecting network recovery. + * Only takes action if we're in disconnected state (waiting to reconnect). + */ + tryReconnectImmediately() { + this._logVerbose("tryReconnectImmediately called"); + if (this.socket.state !== "disconnected") { + this._logVerbose( + `tryReconnectImmediately called but socket state is ${this.socket.state}, no action taken` + ); + return; + } + let timeSavedMs = null; + if (this.scheduledReconnect) { + const elapsed = monotonicMillis() - this.scheduledReconnect.scheduledAt; + timeSavedMs = Math.max(0, this.scheduledReconnect.backoffMs - elapsed); + this._logVerbose( + `would have waited ${Math.round(timeSavedMs)}ms more (backoff was ${Math.round(this.scheduledReconnect.backoffMs)}ms, elapsed ${Math.round(elapsed)}ms)` + ); + clearTimeout(this.scheduledReconnect.timeout); + this.scheduledReconnect = null; + this._logVerbose("canceled scheduled reconnect"); + } + this.logger.log("Network recovery detected, reconnecting immediately"); + this.pendingNetworkRecoveryInfo = timeSavedMs !== null ? { timeSavedMs } : null; + this.connect(); + } + /** + * Resume the state machine if previously paused. + */ + resume() { + switch (this.socket.state) { + case "connecting": + this.socket = { ...this.socket, paused: "no" }; + return; + case "ready": + if (this.socket.paused === "uninitialized") { + this.socket = { ...this.socket, paused: "no" }; + this._hasEverConnected = true; + this.onOpen({ + connectionCount: this.connectionCount, + lastCloseReason: this.lastCloseReason, + clientTs: monotonicMillis() + }); + } else if (this.socket.paused === "yes") { + this.socket = { ...this.socket, paused: "no" }; + this.onResume(); + } + return; + case "terminated": + case "stopped": + case "disconnected": + return; + default: { + this.socket; + } + } + this.connect(); + } + connectionState() { + return { + isConnected: this.socket.state === "ready", + hasEverConnected: this._hasEverConnected, + connectionCount: this.connectionCount, + connectionRetries: this.retries + }; + } + _logVerbose(message) { + this.logger.logVerbose(message); + } + nextBackoff(reason) { + const initialBackoff = reason === "client" ? 100 : reason === "Unknown" ? this.defaultInitialBackoff : serverDisconnectErrors[reason].timeout; + const baseBackoff = initialBackoff * Math.pow(2, this.retries); + this.retries += 1; + const actualBackoff = Math.min(baseBackoff, this.maxBackoff); + const jitter = actualBackoff * (Math.random() - 0.5); + return actualBackoff + jitter; + } + reportLargeTransition({ + transition, + messageLength + }) { + if (transition.clientClockSkew === void 0 || transition.serverTs === void 0) { + return; + } + const transitionTransitTime = monotonicMillis() - // client time now + // clientClockSkew = (server time + upstream latency) - client time + // clientClockSkew is "how many milliseconds behind (slow) is the client clock" + // but the latency of the Connect message inflates this, making it appear further behind + transition.clientClockSkew - transition.serverTs / 1e6; + const prettyTransitionTime = `${Math.round(transitionTransitTime)}ms`; + const prettyMessageMB = `${Math.round(messageLength / 1e4) / 100}MB`; + const bytesPerSecond = messageLength / (transitionTransitTime / 1e3); + const prettyBytesPerSecond = `${Math.round(bytesPerSecond / 1e4) / 100}MB per second`; + this._logVerbose( + `received ${prettyMessageMB} transition in ${prettyTransitionTime} at ${prettyBytesPerSecond}` + ); + if (messageLength > 2e7) { + this.logger.log( + `received query results totaling more that 20MB (${prettyMessageMB}) which will take a long time to download on slower connections` + ); + } else if (transitionTransitTime > 2e4) { + this.logger.log( + `received query results totaling ${prettyMessageMB} which took more than 20s to arrive (${prettyTransitionTime})` + ); + } + if (this.debug) { + this.sendMessage({ + type: "Event", + eventType: "ClientReceivedTransition", + event: { transitionTransitTime, messageLength } + }); + } + } + }; + + // src/browser/sync/session.ts + function newSessionId() { + return uuidv4(); + } + function uuidv4() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0, v = c === "x" ? r : r & 3 | 8; + return v.toString(16); + }); + } + + // src/vendor/jwt-decode/index.ts + var InvalidTokenError = class extends Error { + }; + InvalidTokenError.prototype.name = "InvalidTokenError"; + function b64DecodeUnicode(str) { + return decodeURIComponent( + atob(str).replace(/(.)/g, (_m, p) => { + let code2 = p.charCodeAt(0).toString(16).toUpperCase(); + if (code2.length < 2) { + code2 = "0" + code2; + } + return "%" + code2; + }) + ); + } + function base64UrlDecode(str) { + let output = str.replace(/-/g, "+").replace(/_/g, "/"); + switch (output.length % 4) { + case 0: + break; + case 2: + output += "=="; + break; + case 3: + output += "="; + break; + default: + throw new Error("base64 string is not of the correct length"); + } + try { + return b64DecodeUnicode(output); + } catch { + return atob(output); + } + } + function jwtDecode(token, options) { + if (typeof token !== "string") { + throw new InvalidTokenError("Invalid token specified: must be a string"); + } + options ||= {}; + const pos = options.header === true ? 0 : 1; + const part = token.split(".")[pos]; + if (typeof part !== "string") { + throw new InvalidTokenError( + `Invalid token specified: missing part #${pos + 1}` + ); + } + let decoded; + try { + decoded = base64UrlDecode(part); + } catch (e) { + throw new InvalidTokenError( + `Invalid token specified: invalid base64 for part #${pos + 1} (${e.message})` + ); + } + try { + return JSON.parse(decoded); + } catch (e) { + throw new InvalidTokenError( + `Invalid token specified: invalid json for part #${pos + 1} (${e.message})` + ); + } + } + + // src/browser/sync/authentication_manager.ts + var MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1e3; + var MAX_TOKEN_CONFIRMATION_ATTEMPTS = 2; + var AuthenticationManager = class { + authState = { state: "noAuth" }; + // Used to detect races involving `setConfig` calls + // while a token is being fetched. + configVersion = 0; + // Shared by the BaseClient so that the auth manager can easily inspect it + syncState; + // Passed down by BaseClient, sends a message to the server + authenticate; + stopSocket; + tryRestartSocket; + pauseSocket; + resumeSocket; + // Passed down by BaseClient, sends a message to the server + clearAuth; + logger; + refreshTokenLeewaySeconds; + initialAuthTokenReuse; + // Track last value to avoid redundant calls + lastRefreshChange; + // Number of times we have attempted to confirm the latest token. We retry up + // to `MAX_TOKEN_CONFIRMATION_ATTEMPTS` times. + tokenConfirmationAttempts = 0; + constructor(syncState, callbacks, config) { + this.syncState = syncState; + this.authenticate = callbacks.authenticate; + this.stopSocket = callbacks.stopSocket; + this.tryRestartSocket = callbacks.tryRestartSocket; + this.pauseSocket = callbacks.pauseSocket; + this.resumeSocket = callbacks.resumeSocket; + this.clearAuth = callbacks.clearAuth; + this.logger = config.logger; + this.refreshTokenLeewaySeconds = config.refreshTokenLeewaySeconds; + this.initialAuthTokenReuse = config.initialAuthTokenReuse; + this.lastRefreshChange = false; + } + notifyRefreshChange(isRefreshing) { + if (this.authState.state !== "noAuth" && this.authState.state !== "initialRefetch" && this.authState.config.onRefreshChange && this.lastRefreshChange !== isRefreshing) { + this.lastRefreshChange = isRefreshing; + this.authState.config.onRefreshChange(isRefreshing); + } + } + async setConfig(fetchToken, onChange, onRefreshChange) { + this.resetAuthState(); + this._logVerbose("pausing WS for auth token fetch"); + this.pauseSocket(); + const token = await this.fetchTokenAndGuardAgainstRace(fetchToken, { + forceRefreshToken: false + }); + if (token.isFromOutdatedConfig) { + return; + } + const config = { + fetchToken, + onAuthChange: onChange, + onRefreshChange + }; + if (token.value) { + this.setAuthState({ + state: "waitingForServerConfirmationOfCachedToken", + config, + hasRetried: false + }); + this.authenticate(token.value); + } else { + this.setAuthState({ + state: "initialRefetch", + config + }); + await this.refetchToken(); + } + this._logVerbose("resuming WS after auth token fetch"); + this.resumeSocket(); + } + onTransition(serverMessage) { + if (!this.syncState.isCurrentOrNewerAuthVersion( + serverMessage.endVersion.identity + )) { + return; + } + if (serverMessage.endVersion.identity <= serverMessage.startVersion.identity) { + return; + } + this._logVerbose( + `auth state is ${this.authState.state} when handling transition` + ); + this.syncState.markAuthCompletion(); + if (this.authState.state === "waitingForServerConfirmationOfCachedToken") { + this._logVerbose("server confirmed auth token is valid"); + const cachedToken = this.syncState.getAuth()?.value; + if (this.initialAuthTokenReuse && cachedToken) { + this.scheduleTokenRefetch(cachedToken, serverMessage.clientClockSkew); + } else { + void this.refetchToken(); + } + this.authState.config.onAuthChange(true); + return; + } + if (this.authState.state === "waitingForServerConfirmationOfFreshToken") { + this._logVerbose("server confirmed new auth token is valid"); + this.notifyRefreshChange(false); + this.scheduleTokenRefetch(this.authState.token); + this.tokenConfirmationAttempts = 0; + if (!this.authState.hadAuth) { + this.authState.config.onAuthChange(true); + } + } + } + onAuthError(serverMessage) { + if (serverMessage.authUpdateAttempted === false && (this.authState.state === "waitingForServerConfirmationOfFreshToken" || this.authState.state === "waitingForServerConfirmationOfCachedToken")) { + this._logVerbose("ignoring non-auth token expired error"); + return; + } + const { baseVersion } = serverMessage; + if (!this.syncState.isCurrentOrNewerAuthVersion(baseVersion + 1)) { + this._logVerbose("ignoring auth error for previous auth attempt"); + return; + } + void this.tryToReauthenticate(serverMessage); + return; + } + // This is similar to `refetchToken` defined below, in fact we + // don't represent them as different states, but it is different + // in that we pause the WebSocket so that mutations + // don't retry with bad auth. + async tryToReauthenticate(serverMessage) { + this._logVerbose(`attempting to reauthenticate: ${serverMessage.error}`); + if ( + // No way to fetch another token, kaboom + this.authState.state === "noAuth" || // We failed on a fresh token. After a small number of retries, we give up + // and clear the auth state to avoid infinite retries. + this.authState.state === "waitingForServerConfirmationOfFreshToken" && this.tokenConfirmationAttempts >= MAX_TOKEN_CONFIRMATION_ATTEMPTS + ) { + this.logger.error( + `Failed to authenticate: "${serverMessage.error}", check your server auth config` + ); + if (this.syncState.hasAuth()) { + this.syncState.clearAuth(); + } + if (this.authState.state !== "noAuth") { + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + return; + } + if (this.authState.state === "waitingForServerConfirmationOfFreshToken") { + this.tokenConfirmationAttempts++; + this._logVerbose( + `retrying reauthentication, ${MAX_TOKEN_CONFIRMATION_ATTEMPTS - this.tokenConfirmationAttempts} attempts remaining` + ); + } + this.notifyRefreshChange(true); + await this.stopSocket(); + if (this.authState.state === "noAuth") { + return; + } + const token = await this.fetchTokenAndGuardAgainstRace( + this.authState.config.fetchToken, + { + forceRefreshToken: true + } + ); + if (token.isFromOutdatedConfig) { + return; + } + if (token.value && this.syncState.isNewAuth(token.value)) { + this.authenticate(token.value); + this.setAuthState({ + state: "waitingForServerConfirmationOfFreshToken", + config: this.authState.config, + token: token.value, + hadAuth: this.authState.state === "notRefetching" || this.authState.state === "waitingForScheduledRefetch" + }); + } else { + this._logVerbose("reauthentication failed, could not fetch a new token"); + if (this.syncState.hasAuth()) { + this.syncState.clearAuth(); + } + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + this.tryRestartSocket(); + } + // Force refetch the token and schedule another refetch + // before the token expires - an active client should never + // need to reauthenticate. + async refetchToken() { + if (this.authState.state === "noAuth") { + return; + } + this._logVerbose("refetching auth token"); + const token = await this.fetchTokenAndGuardAgainstRace( + this.authState.config.fetchToken, + { + forceRefreshToken: true + } + ); + if (token.isFromOutdatedConfig) { + return; + } + if (token.value) { + if (this.syncState.isNewAuth(token.value)) { + this.setAuthState({ + state: "waitingForServerConfirmationOfFreshToken", + hadAuth: this.syncState.hasAuth(), + token: token.value, + config: this.authState.config + }); + this.authenticate(token.value); + } else { + this.setAuthState({ + state: "notRefetching", + config: this.authState.config + }); + } + } else { + this._logVerbose("refetching token failed"); + if (this.syncState.hasAuth()) { + this.clearAuth(); + } + this.setAndReportAuthFailed(this.authState.config.onAuthChange); + } + this._logVerbose( + "restarting WS after auth token fetch (if currently stopped)" + ); + this.tryRestartSocket(); + } + scheduleTokenRefetch(token, clientClockSkewMs) { + if (this.authState.state === "noAuth") { + return; + } + const decodedToken = this.decodeToken(token); + if (!decodedToken) { + this.logger.error( + "Auth token is not a valid JWT, cannot refetch the token" + ); + return; + } + const { iat, exp } = decodedToken; + if (!iat || !exp) { + this.logger.error( + "Auth token does not have required fields, cannot refetch the token" + ); + return; + } + const fullLifetimeSeconds = exp - iat; + if (fullLifetimeSeconds <= 2) { + this.logger.error( + "Auth token does not live long enough, cannot refetch the token" + ); + return; + } + let tokenValiditySeconds; + if (clientClockSkewMs !== void 0) { + const estimatedServerNowSeconds = (Date.now() - clientClockSkewMs) / 1e3; + tokenValiditySeconds = exp - estimatedServerNowSeconds; + if (tokenValiditySeconds <= 0) { + tokenValiditySeconds = 0; + } + } else { + tokenValiditySeconds = fullLifetimeSeconds; + } + let delay = Math.min( + MAXIMUM_REFRESH_DELAY, + (tokenValiditySeconds - this.refreshTokenLeewaySeconds) * 1e3 + ); + if (delay <= 0) { + this.logger.warn( + `Refetching auth token immediately, configured leeway ${this.refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s` + ); + delay = 0; + } + const refetchTokenTimeoutId = setTimeout(() => { + this._logVerbose("running scheduled token refetch"); + void this.refetchToken(); + }, delay); + this.setAuthState({ + state: "waitingForScheduledRefetch", + refetchTokenTimeoutId, + config: this.authState.config + }); + this._logVerbose( + `scheduled preemptive auth token refetching in ${delay}ms` + ); + } + // Protects against simultaneous calls to `setConfig` + // while we're fetching a token + async fetchTokenAndGuardAgainstRace(fetchToken, fetchArgs) { + const originalConfigVersion = ++this.configVersion; + this._logVerbose( + `fetching token with config version ${originalConfigVersion}` + ); + const token = await fetchToken(fetchArgs); + if (this.configVersion !== originalConfigVersion) { + this._logVerbose( + `stale config version, expected ${originalConfigVersion}, got ${this.configVersion}` + ); + return { isFromOutdatedConfig: true }; + } + return { isFromOutdatedConfig: false, value: token }; + } + stop() { + this.resetAuthState(); + this.configVersion++; + this._logVerbose(`config version bumped to ${this.configVersion}`); + } + setAndReportAuthFailed(onAuthChange) { + onAuthChange(false); + this.resetAuthState(); + } + // The sole path to `state === "noAuth"`; consumers rely on this firing + // `notifyRefreshChange(false)` to pair any in-flight `(true)`. May run + // when refresh state is already false. + resetAuthState() { + this.notifyRefreshChange(false); + this.setAuthState({ state: "noAuth" }); + } + setAuthState(newAuth) { + const authStateForLog = newAuth.state === "waitingForServerConfirmationOfFreshToken" ? { + hadAuth: newAuth.hadAuth, + state: newAuth.state, + token: `...${newAuth.token.slice(-7)}` + } : { state: newAuth.state }; + this._logVerbose( + `setting auth state to ${JSON.stringify(authStateForLog)}` + ); + switch (newAuth.state) { + case "waitingForScheduledRefetch": + case "notRefetching": + case "noAuth": + this.tokenConfirmationAttempts = 0; + break; + case "waitingForServerConfirmationOfFreshToken": + case "waitingForServerConfirmationOfCachedToken": + case "initialRefetch": + break; + default: { + newAuth; + } + } + if (this.authState.state === "waitingForScheduledRefetch") { + clearTimeout(this.authState.refetchTokenTimeoutId); + } + this.authState = newAuth; + } + decodeToken(token) { + try { + return jwtDecode(token); + } catch (e) { + this._logVerbose( + `Error decoding token: ${e instanceof Error ? e.message : "Unknown error"}` + ); + return null; + } + } + _logVerbose(message) { + this.logger.logVerbose(`${message} [v${this.configVersion}]`); + } + }; + + // src/browser/sync/metrics.ts + var markNames = [ + "convexClientConstructed", + "convexWebSocketOpen", + "convexFirstMessageReceived" + ]; + function mark(name, sessionId) { + const detail = { sessionId }; + if (typeof performance === "undefined" || !performance.mark) return; + performance.mark(name, { detail }); + } + function performanceMarkToJson(mark2) { + let name = mark2.name.slice("convex".length); + name = name.charAt(0).toLowerCase() + name.slice(1); + return { + name, + startTime: mark2.startTime + }; + } + function getMarksReport(sessionId) { + if (typeof performance === "undefined" || !performance.getEntriesByName) { + return []; + } + const allMarks = []; + for (const name of markNames) { + const marks = performance.getEntriesByName(name).filter((entry) => entry.entryType === "mark").filter((mark2) => mark2.detail.sessionId === sessionId); + allMarks.push(...marks); + } + return allMarks.map(performanceMarkToJson); + } + + // src/browser/sync/client.ts + var BaseConvexClient = class { + address; + state; + requestManager; + webSocketManager; + authenticationManager; + remoteQuerySet; + optimisticQueryResults; + _transitionHandlerCounter = 0; + _nextRequestId; + _onTransitionFns = /* @__PURE__ */ new Map(); + _sessionId; + firstMessageReceived = false; + debug; + logger; + maxObservedTimestamp; + connectionStateSubscribers = /* @__PURE__ */ new Map(); + nextConnectionStateSubscriberId = 0; + _lastPublishedConnectionState; + /** + * @param address - The url of your Convex deployment, often provided + * by an environment variable. E.g. `https://small-mouse-123.convex.cloud`. + * @param onTransition - A callback receiving an array of query tokens + * corresponding to query results that have changed -- additional handlers + * can be added via `addOnTransitionHandler`. + * @param options - See {@link BaseConvexClientOptions} for a full description. + */ + constructor(address, onTransition, options) { + if (typeof address === "object") { + throw new Error( + "Passing a ClientConfig object is no longer supported. Pass the URL of the Convex deployment as a string directly." + ); + } + if (options?.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + options = { ...options }; + const authRefreshTokenLeewaySeconds = options.authRefreshTokenLeewaySeconds ?? 10; + let webSocketConstructor = options.webSocketConstructor; + if (!webSocketConstructor && typeof WebSocket === "undefined") { + throw new Error( + "No WebSocket global variable defined! To use Convex in an environment without WebSocket try the HTTP client: https://docs.convex.dev/api/classes/browser.ConvexHttpClient" + ); + } + webSocketConstructor = webSocketConstructor || WebSocket; + this.debug = options.reportDebugInfoToConvex ?? false; + this.address = address; + this.logger = options.logger === false ? instantiateNoopLogger({ verbose: options.verbose ?? false }) : options.logger !== true && options.logger ? options.logger : instantiateDefaultLogger({ verbose: options.verbose ?? false }); + const i = address.search("://"); + if (i === -1) { + throw new Error("Provided address was not an absolute URL."); + } + const origin = address.substring(i + 3); + const protocol = address.substring(0, i); + let wsProtocol; + if (protocol === "http") { + wsProtocol = "ws"; + } else if (protocol === "https") { + wsProtocol = "wss"; + } else { + throw new Error(`Unknown parent protocol ${protocol}`); + } + const wsUri = `${wsProtocol}://${origin}/api/${version}/sync`; + this.state = new LocalSyncState(); + this.remoteQuerySet = new RemoteQuerySet( + (queryId) => this.state.queryPath(queryId), + this.logger + ); + this.requestManager = new RequestManager( + this.logger, + this.markConnectionStateDirty + ); + const pauseSocket = () => { + this.webSocketManager.pause(); + this.state.pause(); + }; + this.authenticationManager = new AuthenticationManager( + this.state, + { + authenticate: (token) => { + const message = this.state.setAuth(token); + this.webSocketManager.sendMessage(message); + return message.baseVersion; + }, + stopSocket: () => this.webSocketManager.stop(), + tryRestartSocket: () => this.webSocketManager.tryRestart(), + pauseSocket, + resumeSocket: () => this.webSocketManager.resume(), + clearAuth: () => { + this.clearAuth(); + } + }, + { + logger: this.logger, + refreshTokenLeewaySeconds: authRefreshTokenLeewaySeconds, + initialAuthTokenReuse: options.initialAuthTokenReuse ?? false + } + ); + this.optimisticQueryResults = new OptimisticQueryResults(); + this.addOnTransitionHandler((transition) => { + onTransition(transition.queries.map((q) => q.token)); + }); + this._nextRequestId = 0; + this._sessionId = newSessionId(); + const { unsavedChangesWarning } = options; + if (typeof window === "undefined" || typeof window.addEventListener === "undefined") { + if (unsavedChangesWarning === true) { + throw new Error( + "unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options." + ); + } + } else if (unsavedChangesWarning !== false) { + window.addEventListener("beforeunload", (e) => { + if (this.requestManager.hasIncompleteRequests()) { + e.preventDefault(); + const confirmationMessage = "Are you sure you want to leave? Your changes may not be saved."; + (e || window.event).returnValue = confirmationMessage; + return confirmationMessage; + } + }); + } + this.webSocketManager = new WebSocketManager( + wsUri, + { + onOpen: (reconnectMetadata) => { + this.mark("convexWebSocketOpen"); + this.webSocketManager.sendMessage({ + ...reconnectMetadata, + type: "Connect", + sessionId: this._sessionId, + maxObservedTimestamp: this.maxObservedTimestamp + }); + this.remoteQuerySet = new RemoteQuerySet( + (queryId) => this.state.queryPath(queryId), + this.logger + ); + const [querySetModification, authModification] = this.state.restart(); + if (authModification) { + this.webSocketManager.sendMessage(authModification); + } + this.webSocketManager.sendMessage(querySetModification); + for (const message of this.requestManager.restart()) { + this.webSocketManager.sendMessage(message); + } + }, + onResume: () => { + const [querySetModification, authModification] = this.state.resume(); + if (authModification) { + this.webSocketManager.sendMessage(authModification); + } + if (querySetModification) { + this.webSocketManager.sendMessage(querySetModification); + } + for (const message of this.requestManager.resume()) { + this.webSocketManager.sendMessage(message); + } + }, + onMessage: (serverMessage) => { + if (!this.firstMessageReceived) { + this.firstMessageReceived = true; + this.mark("convexFirstMessageReceived"); + this.reportMarks(); + } + switch (serverMessage.type) { + case "Transition": { + this.observedTimestamp(serverMessage.endVersion.ts); + this.authenticationManager.onTransition(serverMessage); + this.remoteQuerySet.transition(serverMessage); + this.state.transition(serverMessage); + const completedRequests = this.requestManager.removeCompleted( + this.remoteQuerySet.timestamp() + ); + this.notifyOnQueryResultChanges(completedRequests); + break; + } + case "MutationResponse": { + if (serverMessage.success) { + this.observedTimestamp(serverMessage.ts); + } + const completedMutationInfo = this.requestManager.onResponse(serverMessage); + if (completedMutationInfo !== null) { + this.notifyOnQueryResultChanges( + /* @__PURE__ */ new Map([ + [ + completedMutationInfo.requestId, + completedMutationInfo.result + ] + ]) + ); + } + break; + } + case "ActionResponse": { + this.requestManager.onResponse(serverMessage); + break; + } + case "AuthError": { + this.authenticationManager.onAuthError(serverMessage); + break; + } + case "FatalError": { + const error = logFatalError(this.logger, serverMessage.error); + void this.webSocketManager.terminate(); + throw error; + } + default: { + serverMessage; + } + } + return { + hasSyncedPastLastReconnect: this.hasSyncedPastLastReconnect() + }; + }, + onServerDisconnectError: options.onServerDisconnectError + }, + webSocketConstructor, + this.logger, + this.markConnectionStateDirty, + this.debug + ); + this.mark("convexClientConstructed"); + if (options.expectAuth) { + pauseSocket(); + } + } + /** + * Return true if there is outstanding work from prior to the time of the most recent restart. + * This indicates that the client has not proven itself to have gotten past the issue that + * potentially led to the restart. Use this to influence when to reset backoff after a failure. + */ + hasSyncedPastLastReconnect() { + const hasSyncedPastLastReconnect = this.requestManager.hasSyncedPastLastReconnect() && this.state.hasSyncedPastLastReconnect(); + return hasSyncedPastLastReconnect; + } + observedTimestamp(observedTs) { + if (this.maxObservedTimestamp === void 0 || this.maxObservedTimestamp.lessThanOrEqual(observedTs)) { + this.maxObservedTimestamp = observedTs; + } + } + getMaxObservedTimestamp() { + return this.maxObservedTimestamp; + } + /** + * Compute the current query results based on the remoteQuerySet and the + * current optimistic updates and call `onTransition` for all the changed + * queries. + * + * @param completedMutations - A set of mutation IDs whose optimistic updates + * are no longer needed. + */ + notifyOnQueryResultChanges(completedRequests) { + const remoteQueryResults = this.remoteQuerySet.remoteQueryResults(); + const queryTokenToValue = /* @__PURE__ */ new Map(); + for (const [queryId, result] of remoteQueryResults) { + const queryToken = this.state.queryToken(queryId); + if (queryToken !== null) { + const query = { + result, + udfPath: this.state.queryPath(queryId), + args: this.state.queryArgs(queryId) + }; + queryTokenToValue.set(queryToken, query); + } + } + const changedQueryTokens = this.optimisticQueryResults.ingestQueryResultsFromServer( + queryTokenToValue, + new Set(completedRequests.keys()) + ); + this.handleTransition({ + queries: changedQueryTokens.map((token) => { + const optimisticResult = this.optimisticQueryResults.rawQueryResult(token); + return { + token, + modification: { + kind: "Updated", + result: optimisticResult + } + }; + }), + reflectedMutations: Array.from(completedRequests).map( + ([requestId, result]) => ({ + requestId, + result + }) + ), + timestamp: this.remoteQuerySet.timestamp() + }); + } + handleTransition(transition) { + for (const fn of this._onTransitionFns.values()) { + fn(transition); + } + } + /** + * Add a handler that will be called on a transition. + * + * Any external side effects (e.g. setting React state) should be handled here. + * + * @param fn + * + * @returns + */ + addOnTransitionHandler(fn) { + const id = this._transitionHandlerCounter++; + this._onTransitionFns.set(id, fn); + return () => this._onTransitionFns.delete(id); + } + /** + * Get the current JWT auth token and decoded claims. + */ + getCurrentAuthClaims() { + const authToken = this.state.getAuth(); + let decoded = {}; + if (authToken && authToken.tokenType === "User") { + try { + decoded = authToken ? jwtDecode(authToken.value) : {}; + } catch { + decoded = {}; + } + } else { + return void 0; + } + return { token: authToken.value, decoded }; + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * `fetchToken` will be called automatically again if a token expires. + * `fetchToken` should return `null` if the token cannot be retrieved, for example + * when the user's rights were permanently revoked. + * @param fetchToken - an async function returning the JWT-encoded OpenID Connect Identity Token + * @param onChange - a callback that will be called when the authentication status changes + * @param onRefreshChange - a callback called with `true` when the socket is paused to fetch a replacement token after a server rejection, and `false` when refresh completes + */ + setAuth(fetchToken, onChange, onRefreshChange) { + void this.authenticationManager.setConfig( + fetchToken, + onChange, + onRefreshChange + ); + } + hasAuth() { + return this.state.hasAuth(); + } + /** @internal */ + setAdminAuth(value, fakeUserIdentity) { + const message = this.state.setAdminAuth(value, fakeUserIdentity); + this.webSocketManager.sendMessage(message); + } + clearAuth() { + const message = this.state.clearAuth(); + this.webSocketManager.sendMessage(message); + } + /** + * Subscribe to a query function. + * + * Whenever this query's result changes, the `onTransition` callback + * passed into the constructor will be called. + * + * @param name - The name of the query. + * @param args - An arguments object for the query. If this is omitted, the + * arguments will be `{}`. + * @param options - A {@link SubscribeOptions} options object for this query. + + * @returns An object containing a {@link QueryToken} corresponding to this + * query and an `unsubscribe` callback. + */ + subscribe(name, args, options) { + const argsObject = parseArgs(args); + const { modification, queryToken, unsubscribe } = this.state.subscribe( + name, + argsObject, + options?.journal, + options?.componentPath + ); + if (modification !== null) { + this.webSocketManager.sendMessage(modification); + } + return { + queryToken, + unsubscribe: () => { + const modification2 = unsubscribe(); + if (modification2) { + this.webSocketManager.sendMessage(modification2); + } + } + }; + } + /** + * A query result based only on the current, local state. + * + * The only way this will return a value is if we're already subscribed to the + * query or its value has been set optimistically. + */ + localQueryResult(udfPath, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(udfPath, argsObject); + return this.optimisticQueryResults.queryResult(queryToken); + } + /** + * Get query result by query token based on current, local state + * + * The only way this will return a value is if we're already subscribed to the + * query or its value has been set optimistically. + * + * @internal + */ + localQueryResultByToken(queryToken) { + return this.optimisticQueryResults.queryResult(queryToken); + } + /** + * Whether local query result is available for a token. + * + * This method does not throw if the result is an error. + * + * @internal + */ + hasLocalQueryResultByToken(queryToken) { + return this.optimisticQueryResults.hasQueryResult(queryToken); + } + /** + * @internal + */ + localQueryLogs(udfPath, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(udfPath, argsObject); + return this.optimisticQueryResults.queryLogs(queryToken); + } + /** + * Retrieve the current {@link QueryJournal} for this query function. + * + * If we have not yet received a result for this query, this will be `undefined`. + * + * @param name - The name of the query. + * @param args - The arguments object for this query. + * @returns The query's {@link QueryJournal} or `undefined`. + */ + queryJournal(name, args) { + const argsObject = parseArgs(args); + const queryToken = serializePathAndArgs(name, argsObject); + return this.state.queryJournal(queryToken); + } + /** + * Get the current {@link ConnectionState} between the client and the Convex + * backend. + * + * @returns The {@link ConnectionState} with the Convex backend. + */ + connectionState() { + const wsConnectionState = this.webSocketManager.connectionState(); + return { + hasInflightRequests: this.requestManager.hasInflightRequests(), + isWebSocketConnected: wsConnectionState.isConnected, + hasEverConnected: wsConnectionState.hasEverConnected, + connectionCount: wsConnectionState.connectionCount, + connectionRetries: wsConnectionState.connectionRetries, + timeOfOldestInflightRequest: this.requestManager.timeOfOldestInflightRequest(), + inflightMutations: this.requestManager.inflightMutations(), + inflightActions: this.requestManager.inflightActions() + }; + } + /** + * Call this whenever the connection state may have changed in a way that could + * require publishing it. Schedules a possibly update. + */ + markConnectionStateDirty = () => { + void Promise.resolve().then(() => { + const curConnectionState = this.connectionState(); + if (JSON.stringify(curConnectionState) !== JSON.stringify(this._lastPublishedConnectionState)) { + this._lastPublishedConnectionState = curConnectionState; + for (const cb of this.connectionStateSubscribers.values()) { + cb(curConnectionState); + } + } + }); + }; + /** + * Subscribe to the {@link ConnectionState} between the client and the Convex + * backend, calling a callback each time it changes. + * + * Subscribed callbacks will be called when any part of ConnectionState changes. + * ConnectionState may grow in future versions (e.g. to provide a array of + * inflight requests) in which case callbacks would be called more frequently. + * + * @returns An unsubscribe function to stop listening. + */ + subscribeToConnectionState(cb) { + const id = this.nextConnectionStateSubscriberId++; + this.connectionStateSubscribers.set(id, cb); + return () => { + this.connectionStateSubscribers.delete(id); + }; + } + /** + * Execute a mutation function. + * + * @param name - The name of the mutation. + * @param args - An arguments object for the mutation. If this is omitted, + * the arguments will be `{}`. + * @param options - A {@link MutationOptions} options object for this mutation. + + * @returns - A promise of the mutation's result. + */ + async mutation(name, args, options) { + const result = await this.mutationInternal(name, args, options); + if (!result.success) { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError( + createHybridErrorStacktrace("mutation", name, result) + ) + ); + } + throw new Error(createHybridErrorStacktrace("mutation", name, result)); + } + return result.value; + } + /** + * @internal + */ + async mutationInternal(udfPath, args, options, componentPath) { + const { mutationPromise } = this.enqueueMutation( + udfPath, + args, + options, + componentPath + ); + return mutationPromise; + } + /** + * @internal + */ + enqueueMutation(udfPath, args, options, componentPath) { + const mutationArgs = parseArgs(args); + this.tryReportLongDisconnect(); + const requestId = this.nextRequestId; + this._nextRequestId++; + if (options !== void 0) { + const optimisticUpdate = options.optimisticUpdate; + if (optimisticUpdate !== void 0) { + const wrappedUpdate = (localQueryStore) => { + const result = optimisticUpdate( + localQueryStore, + mutationArgs + ); + if (result instanceof Promise) { + this.logger.warn( + "Optimistic update handler returned a Promise. Optimistic updates should be synchronous." + ); + } + }; + const changedQueryTokens = this.optimisticQueryResults.applyOptimisticUpdate( + wrappedUpdate, + requestId + ); + const changedQueries = changedQueryTokens.map((token) => { + const localResult = this.localQueryResultByToken(token); + return { + token, + modification: { + kind: "Updated", + result: localResult === void 0 ? void 0 : { + success: true, + value: localResult, + logLines: [] + } + } + }; + }); + this.handleTransition({ + queries: changedQueries, + reflectedMutations: [], + timestamp: this.remoteQuerySet.timestamp() + }); + } + } + const message = { + type: "Mutation", + requestId, + udfPath, + componentPath, + args: [convexToJson(mutationArgs)] + }; + const mightBeSent = this.webSocketManager.sendMessage(message); + const mutationPromise = this.requestManager.request(message, mightBeSent); + return { + requestId, + mutationPromise + }; + } + /** + * Execute an action function. + * + * @param name - The name of the action. + * @param args - An arguments object for the action. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the action's result. + */ + async action(name, args) { + const result = await this.actionInternal(name, args); + if (!result.success) { + if (result.errorData !== void 0) { + throw forwardData( + result, + new ConvexError(createHybridErrorStacktrace("action", name, result)) + ); + } + throw new Error(createHybridErrorStacktrace("action", name, result)); + } + return result.value; + } + /** + * @internal + */ + async actionInternal(udfPath, args, componentPath) { + const actionArgs = parseArgs(args); + const requestId = this.nextRequestId; + this._nextRequestId++; + this.tryReportLongDisconnect(); + const message = { + type: "Action", + requestId, + udfPath, + componentPath, + args: [convexToJson(actionArgs)] + }; + const mightBeSent = this.webSocketManager.sendMessage(message); + return this.requestManager.request(message, mightBeSent); + } + /** + * Close any network handles associated with this client and stop all subscriptions. + * + * Call this method when you're done with an {@link BaseConvexClient} to + * dispose of its sockets and resources. + * + * @returns A `Promise` fulfilled when the connection has been completely closed. + */ + async close() { + this.authenticationManager.stop(); + return this.webSocketManager.terminate(); + } + /** + * Return the address for this client, useful for creating a new client. + * + * Not guaranteed to match the address with which this client was constructed: + * it may be canonicalized. + */ + get url() { + return this.address; + } + /** + * @internal + */ + get nextRequestId() { + return this._nextRequestId; + } + /** + * @internal + */ + get sessionId() { + return this._sessionId; + } + // Instance property so that `mark()` doesn't need to be called as a method. + mark = (name) => { + if (this.debug) { + mark(name, this.sessionId); + } + }; + /** + * Reports performance marks to the server. This should only be called when + * we have a functional websocket. + */ + reportMarks() { + if (this.debug) { + const report = getMarksReport(this.sessionId); + this.webSocketManager.sendMessage({ + type: "Event", + eventType: "ClientConnect", + event: report + }); + } + } + tryReportLongDisconnect() { + if (!this.debug) { + return; + } + const timeOfOldestRequest = this.connectionState().timeOfOldestInflightRequest; + if (timeOfOldestRequest === null || Date.now() - timeOfOldestRequest.getTime() <= 60 * 1e3) { + return; + } + const endpoint = `${this.address}/api/debug_event`; + fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }, + body: JSON.stringify({ event: "LongWebsocketDisconnect" }) + }).then((response) => { + if (!response.ok) { + this.logger.warn( + "Analytics request failed with response:", + response.body + ); + } + }).catch((error) => { + this.logger.warn("Analytics response failed with error:", error); + }); + } + }; + + // src/browser/sync/pagination.ts + function asPaginationResult(value) { + if (typeof value !== "object" || value === null || !Array.isArray(value.page) || typeof value.isDone !== "boolean" || typeof value.continueCursor !== "string") { + throw new Error(`Not a valid paginated query result: ${value?.toString()}`); + } + return value; + } + + // src/browser/sync/paginated_query_client.ts + var PaginatedQueryClient = class { + constructor(client, onTransition) { + this.client = client; + this.onTransition = onTransition; + this.lastTransitionTs = Long.fromNumber(0); + this.client.addOnTransitionHandler( + (transition) => this.onBaseTransition(transition) + ); + } + paginatedQuerySet = /* @__PURE__ */ new Map(); + // hold onto a real Transition so we can construct synthetic ones with that timestamp + lastTransitionTs; + /** + * Subscribe to a paginated query. + * + * @param name - The name of the paginated query function + * @param args - Arguments for the query (excluding paginationOpts) + * @param options - Pagination options including initialNumItems + * @returns Object with paginatedQueryToken and unsubscribe function + */ + subscribe(name, args, options) { + const canonicalizedUdfPath = canonicalizeUdfPath(name); + const token = serializePaginatedPathAndArgs( + canonicalizedUdfPath, + args, + options + ); + const unsubscribe = () => this.removePaginatedQuerySubscriber(token); + const existingEntry = this.paginatedQuerySet.get(token); + if (existingEntry) { + existingEntry.numSubscribers += 1; + return { + paginatedQueryToken: token, + unsubscribe + }; + } + this.paginatedQuerySet.set(token, { + token, + canonicalizedUdfPath, + args, + numSubscribers: 1, + options: { initialNumItems: options.initialNumItems }, + nextPageKey: 0, + pageKeys: [], + pageKeyToQuery: /* @__PURE__ */ new Map(), + ongoingSplits: /* @__PURE__ */ new Map(), + skip: false, + id: options.id + }); + this.addPageToPaginatedQuery(token, null, options.initialNumItems); + return { + paginatedQueryToken: token, + unsubscribe + }; + } + /** + * Get current results for a paginated query based on local state. + * + * Throws an error when one of the pages has errored. + */ + localQueryResult(name, args, options) { + const canonicalizedUdfPath = canonicalizeUdfPath(name); + const token = serializePaginatedPathAndArgs( + canonicalizedUdfPath, + args, + options + ); + return this.localQueryResultByToken(token); + } + /** + * @internal + */ + localQueryResultByToken(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + return void 0; + } + const activePages = this.activePageQueryTokens(paginatedQuery); + if (activePages.length === 0) { + return { + results: [], + status: "LoadingFirstPage", + loadMore: (numItems) => { + return this.loadMoreOfPaginatedQuery(token, numItems); + } + }; + } + let allResults = []; + let hasUndefined = false; + let isDone = false; + for (const pageToken of activePages) { + const result = this.client.localQueryResultByToken(pageToken); + if (result === void 0) { + hasUndefined = true; + isDone = false; + continue; + } + const paginationResult = asPaginationResult(result); + allResults = allResults.concat(paginationResult.page); + isDone = !!paginationResult.isDone; + } + let status; + if (hasUndefined) { + status = allResults.length === 0 ? "LoadingFirstPage" : "LoadingMore"; + } else if (isDone) { + status = "Exhausted"; + } else { + status = "CanLoadMore"; + } + return { + results: allResults, + status, + loadMore: (numItems) => { + return this.loadMoreOfPaginatedQuery(token, numItems); + } + }; + } + onBaseTransition(transition) { + const changedBaseTokens = transition.queries.map((q) => q.token); + const changed = this.queriesContainingTokens(changedBaseTokens); + let paginatedQueries = []; + if (changed.length > 0) { + this.processPaginatedQuerySplits( + changed, + (token) => this.client.localQueryResultByToken(token) + ); + paginatedQueries = changed.map((token) => ({ + token, + modification: { + kind: "Updated", + result: this.localQueryResultByToken(token) + } + })); + } + const extendedTransition = { + ...transition, + paginatedQueries + }; + this.onTransition(extendedTransition); + } + /** + * Load more items for a paginated query. + * + * This *always* causes a transition, the status of the query + * has probably changed from "CanLoadMore" to "LoadingMore". + * Data might have changed too: maybe a subscription to this page + * query already exists (unlikely but possible) or this page query + * has an optimistic update providing some initial data. + * + * @internal + */ + loadMoreOfPaginatedQuery(token, numItems) { + this.mustGetPaginatedQuery(token); + const lastPageToken = this.queryTokenForLastPageOfPaginatedQuery(token); + const lastPageResult = this.client.localQueryResultByToken(lastPageToken); + if (!lastPageResult) { + return false; + } + const paginationResult = asPaginationResult(lastPageResult); + if (paginationResult.isDone) { + return false; + } + this.addPageToPaginatedQuery( + token, + paginationResult.continueCursor, + numItems + ); + const loadMoreTransition = { + timestamp: this.lastTransitionTs, + reflectedMutations: [], + queries: [], + paginatedQueries: [ + { + token, + modification: { + kind: "Updated", + result: this.localQueryResultByToken(token) + } + } + ] + }; + this.onTransition(loadMoreTransition); + return true; + } + /** + * @internal + */ + queriesContainingTokens(queryTokens) { + if (queryTokens.length === 0) { + return []; + } + const changed = []; + const queryTokenSet = new Set(queryTokens); + for (const [paginatedToken, paginatedQuery] of this.paginatedQuerySet) { + for (const pageToken of this.allQueryTokens(paginatedQuery)) { + if (queryTokenSet.has(pageToken)) { + changed.push(paginatedToken); + break; + } + } + } + return changed; + } + /** + * @internal + */ + processPaginatedQuerySplits(changed, getResult) { + for (const paginatedQueryToken of changed) { + const paginatedQuery = this.mustGetPaginatedQuery(paginatedQueryToken); + const { ongoingSplits, pageKeyToQuery, pageKeys } = paginatedQuery; + for (const [pageKey, [splitKey1, splitKey2]] of ongoingSplits) { + const bothNewPagesLoaded = getResult(pageKeyToQuery.get(splitKey1).queryToken) !== void 0 && getResult(pageKeyToQuery.get(splitKey2).queryToken) !== void 0; + if (bothNewPagesLoaded) { + this.completePaginatedQuerySplit( + paginatedQuery, + pageKey, + splitKey1, + splitKey2 + ); + } + } + for (const pageKey of pageKeys) { + if (ongoingSplits.has(pageKey)) { + continue; + } + const pageEntry = pageKeyToQuery.get(pageKey); + if (!pageEntry) { + throw new Error(`No page query for active pageKey ${pageKey}`); + } + const pageResult = getResult(pageEntry.queryToken); + if (!pageResult) { + continue; + } + const result = asPaginationResult(pageResult); + const shouldSplit = result.splitCursor && (result.pageStatus === "SplitRecommended" || result.pageStatus === "SplitRequired" || // This client-driven page splitting condition will change in the future. + result.page.length > paginatedQuery.options.initialNumItems * 2); + if (shouldSplit) { + this.splitPaginatedQueryPage( + paginatedQuery, + pageKey, + pageEntry.cursor, + result.splitCursor, + // we just checked + result.continueCursor + ); + } + } + } + } + splitPaginatedQueryPage(paginatedQuery, pageKey, startCursor, splitCursor, continueCursor) { + const splitKey1 = paginatedQuery.nextPageKey++; + const splitKey2 = paginatedQuery.nextPageKey++; + const paginationOpts = { + numItems: paginatedQuery.options.initialNumItems, + id: paginatedQuery.id + }; + const firstSubscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + { + ...paginatedQuery.args, + paginationOpts: { + ...paginationOpts, + cursor: startCursor, + endCursor: splitCursor + } + } + ); + paginatedQuery.pageKeyToQuery.set(splitKey1, { + ...firstSubscription, + cursor: startCursor + }); + const secondSubscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + { + ...paginatedQuery.args, + paginationOpts: { + ...paginationOpts, + cursor: splitCursor, + endCursor: continueCursor + } + } + ); + paginatedQuery.pageKeyToQuery.set(splitKey2, { + ...secondSubscription, + cursor: splitCursor + }); + paginatedQuery.ongoingSplits.set(pageKey, [splitKey1, splitKey2]); + } + /** + * @internal + */ + addPageToPaginatedQuery(token, continueCursor, numItems) { + const paginatedQuery = this.mustGetPaginatedQuery(token); + const pageKey = paginatedQuery.nextPageKey++; + const paginationOpts = { + cursor: continueCursor, + numItems, + id: paginatedQuery.id + }; + const pageArgs = { + ...paginatedQuery.args, + paginationOpts + }; + const subscription = this.client.subscribe( + paginatedQuery.canonicalizedUdfPath, + pageArgs + ); + paginatedQuery.pageKeys.push(pageKey); + paginatedQuery.pageKeyToQuery.set(pageKey, { + ...subscription, + cursor: continueCursor + }); + return subscription; + } + removePaginatedQuerySubscriber(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + return; + } + paginatedQuery.numSubscribers -= 1; + if (paginatedQuery.numSubscribers > 0) { + return; + } + for (const subscription of paginatedQuery.pageKeyToQuery.values()) { + subscription.unsubscribe(); + } + this.paginatedQuerySet.delete(token); + } + completePaginatedQuerySplit(paginatedQuery, pageKey, splitKey1, splitKey2) { + const originalQuery = paginatedQuery.pageKeyToQuery.get(pageKey); + paginatedQuery.pageKeyToQuery.delete(pageKey); + const pageIndex = paginatedQuery.pageKeys.indexOf(pageKey); + paginatedQuery.pageKeys.splice(pageIndex, 1, splitKey1, splitKey2); + paginatedQuery.ongoingSplits.delete(pageKey); + originalQuery.unsubscribe(); + } + /** The query tokens for all active pages, in result order */ + activePageQueryTokens(paginatedQuery) { + return paginatedQuery.pageKeys.map( + (pageKey) => paginatedQuery.pageKeyToQuery.get(pageKey).queryToken + ); + } + allQueryTokens(paginatedQuery) { + return Array.from(paginatedQuery.pageKeyToQuery.values()).map( + (sub) => sub.queryToken + ); + } + queryTokenForLastPageOfPaginatedQuery(token) { + const paginatedQuery = this.mustGetPaginatedQuery(token); + const lastPageKey = paginatedQuery.pageKeys[paginatedQuery.pageKeys.length - 1]; + if (lastPageKey === void 0) { + throw new Error(`No pages for paginated query ${token}`); + } + return paginatedQuery.pageKeyToQuery.get(lastPageKey).queryToken; + } + mustGetPaginatedQuery(token) { + const paginatedQuery = this.paginatedQuerySet.get(token); + if (!paginatedQuery) { + throw new Error("paginated query no longer exists for token " + token); + } + return paginatedQuery; + } + }; + + // src/browser/simple_client.ts + var defaultWebSocketConstructor; + var ConvexClient = class { + listeners; + _client; + _paginatedClient; + // A synthetic server event to run callbacks the first time + callNewListenersWithCurrentValuesTimer; + _closed; + _disabled; + /** + * Once closed no registered callbacks will fire again. + */ + get closed() { + return this._closed; + } + get client() { + if (this._client) return this._client; + throw new Error("ConvexClient is disabled"); + } + /** + * @internal + */ + get paginatedClient() { + if (this._paginatedClient) return this._paginatedClient; + throw new Error("ConvexClient is disabled"); + } + get disabled() { + return this._disabled; + } + /** + * Construct a client and immediately initiate a WebSocket connection to the passed address. + * + * @public + */ + constructor(address, options = {}) { + if (options.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + const { disabled, ...baseOptions } = options; + this._closed = false; + this._disabled = !!disabled; + if (defaultWebSocketConstructor && !("webSocketConstructor" in baseOptions) && typeof WebSocket === "undefined") { + baseOptions.webSocketConstructor = defaultWebSocketConstructor; + } + if (typeof window === "undefined" && !("unsavedChangesWarning" in baseOptions)) { + baseOptions.unsavedChangesWarning = false; + } + if (!this.disabled) { + this._client = new BaseConvexClient( + address, + () => { + }, + // NOP, let the paginated query client do it all + baseOptions + ); + this._paginatedClient = new PaginatedQueryClient( + this._client, + (transition) => this._transition(transition) + ); + } + this.listeners = /* @__PURE__ */ new Set(); + } + /** + * Call a callback whenever a new result for a query is received. The callback + * will run soon after being registered if a result for the query is already + * in memory. + * + * The return value is an {@link Unsubscribe} object which is both a function + * an an object with properties. Both of the patterns below work with this object: + * + *```ts + * // call the return value as a function + * const unsubscribe = client.onUpdate(api.messages.list, {}, (messages) => { + * console.log(messages); + * }); + * unsubscribe(); + * + * // unpack the return value into its properties + * const { + * getCurrentValue, + * unsubscribe, + * } = client.onUpdate(api.messages.list, {}, (messages) => { + * console.log(messages); + * }); + *``` + * + * @param query - A {@link server.FunctionReference} for the public query to run. + * @param args - The arguments to run the query with. + * @param callback - Function to call when the query result updates. + * @param onError - Function to call when the query result updates with an error. + * If not provided, errors will be thrown instead of calling the callback. + * + * @return an {@link Unsubscribe} function to stop calling the onUpdate function. + */ + onUpdate(query, args, callback, onError) { + if (this.disabled) { + return this.createDisabledUnsubscribe(); + } + const { queryToken, unsubscribe } = this.client.subscribe( + getFunctionName(query), + args + ); + const queryInfo = { + queryToken, + callback, + onError, + unsubscribe, + hasEverRun: false, + query, + args, + paginationOptions: void 0 + }; + this.listeners.add(queryInfo); + if (this.queryResultReady(queryToken) && this.callNewListenersWithCurrentValuesTimer === void 0) { + this.callNewListenersWithCurrentValuesTimer = setTimeout( + () => this.callNewListenersWithCurrentValues(), + 0 + ); + } + const unsubscribeProps = { + unsubscribe: () => { + if (this.closed) { + return; + } + this.listeners.delete(queryInfo); + unsubscribe(); + }, + getCurrentValue: () => this.client.localQueryResultByToken(queryToken), + getQueryLogs: () => this.client.localQueryLogs(queryToken) + }; + const ret = unsubscribeProps.unsubscribe; + Object.assign(ret, unsubscribeProps); + return ret; + } + /** + * Call a callback whenever a new result for a paginated query is received. + * + * This is an experimental preview: the final API may change. + * In particular, caching behavior, page splitting, and required paginated query options + * may change. + * + * @param query - A {@link server.FunctionReference} for the public query to run. + * @param args - The arguments to run the query with. + * @param options - Options for the paginated query including initialNumItems and id. + * @param callback - Function to call when the query result updates. + * @param onError - Function to call when the query result updates with an error. + * + * @return an {@link Unsubscribe} function to stop calling the callback. + */ + onPaginatedUpdate_experimental(query, args, options, callback, onError) { + if (this.disabled) { + return this.createDisabledUnsubscribe(); + } + const paginationOptions = { + initialNumItems: options.initialNumItems, + id: -1 + }; + const { paginatedQueryToken, unsubscribe } = this.paginatedClient.subscribe( + getFunctionName(query), + args, + // Simple client doesn't use IDs, there's no expectation that these queries remain separate. + paginationOptions + ); + const queryInfo = { + queryToken: paginatedQueryToken, + callback, + onError, + unsubscribe, + hasEverRun: false, + query, + args, + paginationOptions + }; + this.listeners.add(queryInfo); + if (!!this.paginatedClient.localQueryResultByToken(paginatedQueryToken) && this.callNewListenersWithCurrentValuesTimer === void 0) { + this.callNewListenersWithCurrentValuesTimer = setTimeout( + () => this.callNewListenersWithCurrentValues(), + 0 + ); + } + const unsubscribeProps = { + unsubscribe: () => { + if (this.closed) { + return; + } + this.listeners.delete(queryInfo); + unsubscribe(); + }, + getCurrentValue: () => { + const result = this.paginatedClient.localQueryResult( + getFunctionName(query), + args, + paginationOptions + ); + return result; + }, + getQueryLogs: () => [] + // Paginated queries don't aggregate their logs + }; + const ret = unsubscribeProps.unsubscribe; + Object.assign(ret, unsubscribeProps); + return ret; + } + // Run all callbacks that have never been run before if they have a query + // result available now. + callNewListenersWithCurrentValues() { + this.callNewListenersWithCurrentValuesTimer = void 0; + this._transition({ queries: [], paginatedQueries: [] }, true); + } + queryResultReady(queryToken) { + return this.client.hasLocalQueryResultByToken(queryToken); + } + createDisabledUnsubscribe() { + const disabledUnsubscribe = (() => { + }); + const unsubscribeProps = { + unsubscribe: disabledUnsubscribe, + getCurrentValue: () => void 0, + getQueryLogs: () => void 0 + }; + Object.assign(disabledUnsubscribe, unsubscribeProps); + return disabledUnsubscribe; + } + async close() { + if (this.disabled) return; + this.listeners.clear(); + this._closed = true; + if (this._paginatedClient) { + this._paginatedClient = void 0; + } + return this.client.close(); + } + /** + * Get the current JWT auth token and decoded claims. + */ + getAuth() { + if (this.disabled) return; + return this.client.getCurrentAuthClaims(); + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * `fetchToken` will be called automatically again if a token expires. + * `fetchToken` should return `null` if the token cannot be retrieved, for example + * when the user's rights were permanently revoked. + * @param fetchToken - an async function returning the JWT (typically an OpenID Connect Identity Token) + * @param onChange - a callback that will be called when the authentication status changes + */ + setAuth(fetchToken, onChange) { + if (this.disabled) return; + this.client.setAuth( + fetchToken, + onChange ?? (() => { + }) + ); + } + /** + * @internal + */ + setAdminAuth(token, identity) { + if (this.closed) { + throw new Error("ConvexClient has already been closed."); + } + if (this.disabled) return; + this.client.setAdminAuth(token, identity); + } + /** + * @internal + */ + _transition({ + queries, + paginatedQueries + }, callNewListeners = false) { + const updatedQueries = [ + ...queries.map((q) => q.token), + ...paginatedQueries.map((q) => q.token) + ]; + for (const queryInfo of this.listeners) { + const { callback, queryToken, onError, hasEverRun } = queryInfo; + const isPaginatedQuery = serializedQueryTokenIsPaginated(queryToken); + const hasResultReady = isPaginatedQuery ? !!this.paginatedClient.localQueryResultByToken(queryToken) : this.client.hasLocalQueryResultByToken(queryToken); + if (updatedQueries.includes(queryToken) || callNewListeners && !hasEverRun && hasResultReady) { + queryInfo.hasEverRun = true; + let newValue; + try { + if (isPaginatedQuery) { + newValue = this.paginatedClient.localQueryResultByToken(queryToken); + } else { + newValue = this.client.localQueryResultByToken(queryToken); + } + } catch (error) { + if (!(error instanceof Error)) throw error; + if (onError) { + onError( + error, + "Second argument to onUpdate onError is reserved for later use" + ); + } else { + void Promise.reject(error); + } + continue; + } + callback( + newValue, + "Second argument to onUpdate callback is reserved for later use" + ); + } + } + } + /** + * Execute a mutation function. + * + * @param mutation - A {@link server.FunctionReference} for the public mutation + * to run. + * @param args - An arguments object for the mutation. + * @param options - A {@link MutationOptions} options object for the mutation. + * @returns A promise of the mutation's result. + */ + async mutation(mutation, args, options) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return await this.client.mutation(getFunctionName(mutation), args, options); + } + /** + * Execute an action function. + * + * @param action - A {@link server.FunctionReference} for the public action + * to run. + * @param args - An arguments object for the action. + * @returns A promise of the action's result. + */ + async action(action, args) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return await this.client.action(getFunctionName(action), args); + } + /** + * Fetch a query result once. + * + * @param query - A {@link server.FunctionReference} for the public query + * to run. + * @param args - An arguments object for the query. + * @returns A promise of the query's result. + */ + async query(query, args) { + if (this.disabled) throw new Error("ConvexClient is disabled"); + const value = this.client.localQueryResult(getFunctionName(query), args); + if (value !== void 0) return Promise.resolve(value); + return new Promise((resolve, reject) => { + const { unsubscribe } = this.onUpdate( + query, + args, + (value2) => { + unsubscribe(); + resolve(value2); + }, + (e) => { + unsubscribe(); + reject(e); + } + ); + }); + } + /** + * Get the current {@link ConnectionState} between the client and the Convex + * backend. + * + * @returns The {@link ConnectionState} with the Convex backend. + */ + connectionState() { + if (this.disabled) throw new Error("ConvexClient is disabled"); + return this.client.connectionState(); + } + /** + * Subscribe to the {@link ConnectionState} between the client and the Convex + * backend, calling a callback each time it changes. + * + * Subscribed callbacks will be called when any part of ConnectionState changes. + * ConnectionState may grow in future versions (e.g. to provide a array of + * inflight requests) in which case callbacks would be called more frequently. + * + * @returns An unsubscribe function to stop listening. + */ + subscribeToConnectionState(cb) { + if (this.disabled) return () => { + }; + return this.client.subscribeToConnectionState(cb); + } + }; + + // src/browser/http_client.ts + var STATUS_CODE_UDF_FAILED = 560; + var specifiedFetch = void 0; + var ConvexHttpClient = class { + address; + auth; + adminAuth; + encodedTsPromise; + debug; + fetchOptions; + fetch; + logger; + mutationQueue = []; + isProcessingQueue = false; + /** + * Create a new {@link ConvexHttpClient}. + * + * @param address - The url of your Convex deployment, often provided + * by an environment variable. E.g. `https://small-mouse-123.convex.cloud`. + * @param options - An object of options. + * - `skipConvexDeploymentUrlCheck` - Skip validating that the Convex deployment URL looks like + * `https://happy-animal-123.convex.cloud` or localhost. This can be useful if running a self-hosted + * Convex backend that uses a different URL. + * - `logger` - A logger or a boolean. If not provided, logs to the console. + * You can construct your own logger to customize logging to log elsewhere + * or not log at all, or use `false` as a shorthand for a no-op logger. + * A logger is an object with 4 methods: log(), warn(), error(), and logVerbose(). + * These methods can receive multiple arguments of any types, like console.log(). + * - `auth` - A JWT containing identity claims accessible in Convex functions. + * This identity may expire so it may be necessary to call `setAuth()` later, + * but for short-lived clients it's convenient to specify this value here. + * - `fetch` - A custom fetch implementation to use for all HTTP requests made by this client. + */ + constructor(address, options) { + if (typeof options === "boolean") { + throw new Error( + "skipConvexDeploymentUrlCheck as the second argument is no longer supported. Please pass an options object, `{ skipConvexDeploymentUrlCheck: true }`." + ); + } + const opts = options ?? {}; + if (opts.skipConvexDeploymentUrlCheck !== true) { + validateDeploymentUrl(address); + } + this.logger = options?.logger === false ? instantiateNoopLogger({ verbose: false }) : options?.logger !== true && options?.logger ? options.logger : instantiateDefaultLogger({ verbose: false }); + this.address = address; + this.debug = true; + this.auth = void 0; + this.adminAuth = void 0; + this.fetch = options?.fetch; + if (options?.auth) { + this.setAuth(options.auth); + } + } + /** + * Obtain the {@link ConvexHttpClient}'s URL to its backend. + * @deprecated Use url, which returns the url without /api at the end. + * + * @returns The URL to the Convex backend, including the client's API version. + */ + backendUrl() { + return `${this.address}/api`; + } + /** + * Return the address for this client, useful for creating a new client. + * + * Not guaranteed to match the address with which this client was constructed: + * it may be canonicalized. + */ + get url() { + return this.address; + } + /** + * Set the authentication token to be used for subsequent queries and mutations. + * + * Should be called whenever the token changes (i.e. due to expiration and refresh). + * + * @param value - JWT-encoded OpenID Connect identity token. + */ + setAuth(value) { + this.clearAuth(); + this.auth = value; + } + /** + * Set admin auth token to allow calling internal queries, mutations, and actions + * and acting as an identity. + * + * @internal + */ + setAdminAuth(token, actingAsIdentity) { + this.clearAuth(); + if (actingAsIdentity !== void 0) { + const bytes = new TextEncoder().encode(JSON.stringify(actingAsIdentity)); + const actingAsIdentityEncoded = btoa(String.fromCodePoint(...bytes)); + this.adminAuth = `${token}:${actingAsIdentityEncoded}`; + } else { + this.adminAuth = token; + } + } + /** + * Clear the current authentication token if set. + */ + clearAuth() { + this.auth = void 0; + this.adminAuth = void 0; + } + /** + * Sets whether the result log lines should be printed on the console or not. + * + * @internal + */ + setDebug(debug) { + this.debug = debug; + } + /** + * Used to customize the fetch behavior in some runtimes. + * + * @internal + */ + setFetchOptions(fetchOptions) { + this.fetchOptions = fetchOptions; + } + /** + * This API is experimental: it may change or disappear. + * + * Execute a Convex query function at the same timestamp as every other + * consistent query execution run by this HTTP client. + * + * This doesn't make sense for long-lived ConvexHttpClients as Convex + * backends can read a limited amount into the past: beyond 30 seconds + * in the past may not be available. + * + * Create a new client to use a consistent time. + * + * @param name - The name of the query. + * @param args - The arguments object for the query. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the query's result. + * + * @deprecated This API is experimental: it may change or disappear. + */ + async consistentQuery(query, ...args) { + const queryArgs = parseArgs(args[0]); + const timestampPromise = this.getTimestamp(); + return await this.queryInner(query, queryArgs, { timestampPromise }); + } + async getTimestamp() { + if (this.encodedTsPromise) { + return this.encodedTsPromise; + } + return this.encodedTsPromise = this.getTimestampInner(); + } + async getTimestampInner() { + const localFetch = this.fetch || specifiedFetch || fetch; + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + const response = await localFetch(`${this.address}/api/query_ts`, { + ...this.fetchOptions, + method: "POST", + headers + }); + if (!response.ok) { + throw new Error(await response.text()); + } + const { ts } = await response.json(); + return ts; + } + /** + * Execute a Convex query function. + * + * @param name - The name of the query. + * @param args - The arguments object for the query. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the query's result. + */ + async query(query, ...args) { + const queryArgs = parseArgs(args[0]); + return await this.queryInner(query, queryArgs, {}); + } + async queryInner(query, queryArgs, options) { + const name = getFunctionName(query); + const args = [convexToJson(queryArgs)]; + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const timestamp = options.timestampPromise ? await options.timestampPromise : void 0; + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args, + ...timestamp ? { ts: timestamp } : {} + }); + const endpoint = timestamp ? `${this.address}/api/query_at_ts` : `${this.address}/api/query`; + const response = await localFetch(endpoint, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "query", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + async mutationInner(mutation, mutationArgs) { + const name = getFunctionName(mutation); + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args: [convexToJson(mutationArgs)] + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/mutation`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "mutation", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + async processMutationQueue() { + if (this.isProcessingQueue) { + return; + } + this.isProcessingQueue = true; + while (this.mutationQueue.length > 0) { + const { mutation, args, resolve, reject } = this.mutationQueue.shift(); + try { + const result = await this.mutationInner(mutation, args); + resolve(result); + } catch (error) { + reject(error); + } + } + this.isProcessingQueue = false; + } + enqueueMutation(mutation, args) { + return new Promise((resolve, reject) => { + this.mutationQueue.push({ mutation, args, resolve, reject }); + void this.processMutationQueue(); + }); + } + /** + * Execute a Convex mutation function. Mutations are queued by default. + * + * @param name - The name of the mutation. + * @param args - The arguments object for the mutation. If this is omitted, + * the arguments will be `{}`. + * @param options - An optional object containing + * @returns A promise of the mutation's result. + */ + async mutation(mutation, ...args) { + const [fnArgs, options] = args; + const mutationArgs = parseArgs(fnArgs); + const queued = !options?.skipQueue; + if (queued) { + return await this.enqueueMutation(mutation, mutationArgs); + } else { + return await this.mutationInner(mutation, mutationArgs); + } + } + /** + * Execute a Convex action function. Actions are not queued. + * + * @param name - The name of the action. + * @param args - The arguments object for the action. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the action's result. + */ + async action(action, ...args) { + const actionArgs = parseArgs(args[0]); + const name = getFunctionName(action); + const body = JSON.stringify({ + path: name, + format: "convex_encoded_json", + args: [convexToJson(actionArgs)] + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/action`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "action", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + /** + * Execute a Convex function of an unknown type. These function calls are not queued. + * + * @param name - The name of the function. + * @param args - The arguments object for the function. If this is omitted, + * the arguments will be `{}`. + * @returns A promise of the function's result. + * + * @internal + */ + async function(anyFunction, componentPath, ...args) { + const functionArgs = parseArgs(args[0]); + const name = typeof anyFunction === "string" ? anyFunction : getFunctionName(anyFunction); + const body = JSON.stringify({ + componentPath, + path: name, + format: "convex_encoded_json", + args: convexToJson(functionArgs) + }); + const headers = { + "Content-Type": "application/json", + "Convex-Client": `npm-${version}` + }; + if (this.adminAuth) { + headers["Authorization"] = `Convex ${this.adminAuth}`; + } else if (this.auth) { + headers["Authorization"] = `Bearer ${this.auth}`; + } + const localFetch = this.fetch || specifiedFetch || fetch; + const response = await localFetch(`${this.address}/api/function`, { + ...this.fetchOptions, + body, + method: "POST", + headers + }); + if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) { + throw new Error(await response.text()); + } + const respJSON = await response.json(); + if (this.debug) { + for (const line of respJSON.logLines ?? []) { + logForFunction(this.logger, "info", "any", name, line); + } + } + switch (respJSON.status) { + case "success": + return jsonToConvex(respJSON.value); + case "error": + if (respJSON.errorData !== void 0) { + throw forwardErrorData( + respJSON.errorData, + new ConvexError(respJSON.errorMessage) + ); + } + throw new Error(respJSON.errorMessage); + default: + throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`); + } + } + }; + function forwardErrorData(errorData, error) { + error.data = jsonToConvex(errorData); + return error; + } + + // src/browser/query_options.ts + function convexQueryOptions(options) { + return options; + } + return __toCommonJS(browser_bundle_exports); +})(); +//# sourceMappingURL=browser.bundle.js.map diff --git a/web/index.html b/web/index.html index 19f9dafb..40f59cea 100644 --- a/web/index.html +++ b/web/index.html @@ -18,21 +18,28 @@ - + + - + - icarus + Icarus + + diff --git a/web/manifest.json b/web/manifest.json index 5455636c..1eb164fa 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,12 +1,11 @@ { - "name": "icarus", - "short_name": "icarus", + "name": "Icarus", + "short_name": "Icarus", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", + "background_color": "#09090b", + "theme_color": "#09090b", + "description": "Create, sync, and share Valorant strategies.", "prefer_related_applications": false, "icons": [ { diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 21704c28..268b13db 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -16,6 +17,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); DesktopUpdaterPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0d2f208..7c27cb03 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links desktop_drop desktop_updater flutter_inappwebview_windows @@ -14,6 +15,8 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + convex_flutter + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index dc856b09..c6fdbf99 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -1,12 +1,106 @@ #include #include +#include #include - +#include "app_links/app_links_plugin_c_api.h" #include "flutter_window.h" #include "utils.h" +namespace { +void DebugLog(const std::wstring& message) { + ::OutputDebugStringW((L"[icarus] " + message + L"\n").c_str()); +} + +bool WindowTitleContains(HWND hwnd, const std::wstring& needle) { + const int titleLength = ::GetWindowTextLengthW(hwnd); + if (titleLength <= 0) { + return false; + } + + std::wstring title(titleLength, L'\0'); + ::GetWindowTextW(hwnd, title.data(), titleLength + 1); + return title.find(needle) != std::wstring::npos; +} + +// True when the only argument is an icarus:// link, the way Windows launches +// us for OAuth callbacks and share links. +bool LaunchedWithAppLink() { + int argc = 0; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + const bool is_app_link = + argv != nullptr && argc == 2 && ::_wcsnicmp(argv[1], L"icarus:", 7) == 0; + ::LocalFree(argv); + return is_app_link; +} +} // namespace + +bool SendAppLinkToInstance(const std::wstring& title) { + // Find our exact window + HWND hwnd = ::FindWindow(L"FLUTTER_RUNNER_WIN32_WINDOW", title.c_str()); + + // The app title is changed later by Dart code, so fallback to scanning + // Flutter runner windows and matching the runtime title variant. + if (!hwnd) { + HWND next = nullptr; + while ((next = ::FindWindowEx(nullptr, next, L"FLUTTER_RUNNER_WIN32_WINDOW", + nullptr)) != nullptr) { + if (WindowTitleContains(next, L"Icarus")) { + hwnd = next; + break; + } + } + } + + if (hwnd) { + DebugLog(L"Existing window found. Forwarding app link to running instance."); + + // Dispatch new link to current window + SendAppLink(hwnd); + + // (Optional) Restore our window to front in same state + WINDOWPLACEMENT place = { sizeof(WINDOWPLACEMENT) }; + GetWindowPlacement(hwnd, &place); + + switch(place.showCmd) { + case SW_SHOWMAXIMIZED: + ShowWindow(hwnd, SW_SHOWMAXIMIZED); + break; + case SW_SHOWMINIMIZED: + ShowWindow(hwnd, SW_RESTORE); + break; + default: + ShowWindow(hwnd, SW_NORMAL); + break; + } + + SetWindowPos(hwnd, HWND_TOP, 0, 0, 0, 0, + SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE); + SetForegroundWindow(hwnd); + // END (Optional) Restore + + // Window has been found, don't create another one. + return true; + } + + DebugLog(L"No existing window found. Continuing cold start."); + return false; +} + int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, _In_ wchar_t *command_line, _In_ int show_command) { + DebugLog(L"wWinMain entered."); + DebugLog(std::wstring(L"Raw command line: ") + + (command_line ? command_line : L"")); + + // Only an icarus:// link (OAuth callbacks, share links) is handed to a + // running window here, because app_links forwards nothing else: exiting on + // any other launch would drop it. Everything else (an .ica path, + // --hive-store-dir, no arguments) goes on to Dart, whose per-store + // single-instance check hands file opens to the window using that store. + if (LaunchedWithAppLink() && SendAppLinkToInstance(L"icarus")) { + DebugLog(L"App link forwarded to existing instance. Exiting."); + return EXIT_SUCCESS; + } // Attach to console when present (e.g., 'flutter run') or create a // new console when running with a debugger. if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { @@ -22,6 +116,9 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, std::vector command_line_arguments = GetCommandLineArguments(); + for (const auto& arg : command_line_arguments) { + DebugLog(std::wstring(L"CLI arg: ") + std::wstring(arg.begin(), arg.end())); + } project.set_dart_entrypoint_arguments(std::move(command_line_arguments));